feat: Phase 4 - password validation, force password reset flow, account lockout, QR code for MFA
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 6s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 6s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
@ -3,8 +3,12 @@ import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
Alert, CircularProgress, Paper, InputAdornment, IconButton,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
} from '@mui/material'
|
||||
import { Visibility, VisibilityOff } from '@mui/icons-material'
|
||||
import {
|
||||
Visibility, VisibilityOff,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User } from '../types'
|
||||
@ -32,6 +36,16 @@ function getErrorMessage(err: unknown): string {
|
||||
return 'MFA_REQUIRED' // sentinel — caller checks this
|
||||
}
|
||||
|
||||
// Password reset required
|
||||
if (code === 'password_reset_required') {
|
||||
return 'PASSWORD_RESET_REQUIRED'
|
||||
}
|
||||
|
||||
// Account locked
|
||||
if (code === 'account_locked') {
|
||||
return 'ACCOUNT_LOCKED'
|
||||
}
|
||||
|
||||
// Account disabled
|
||||
if (code === 'account_disabled') {
|
||||
return 'This account has been disabled. Contact your administrator.'
|
||||
@ -56,6 +70,21 @@ function getErrorMessage(err: unknown): string {
|
||||
return 'Login failed. Please try again.'
|
||||
}
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setTokens, setUser } = useAuthStore()
|
||||
@ -65,9 +94,20 @@ export default function LoginPage() {
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [needsMfa, setNeedsMfa] = useState(false)
|
||||
const [forcePasswordReset, setForcePasswordReset] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Force change password state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('')
|
||||
const [showNewPassword, setShowNewPassword] = useState(false)
|
||||
const [passwordChanged, setPasswordChanged] = useState(false)
|
||||
|
||||
const pwChecks = checkPasswordStrength(newPassword)
|
||||
const pwValid = isPasswordValid(pwChecks)
|
||||
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
@ -84,6 +124,11 @@ export default function LoginPage() {
|
||||
if (message === 'MFA_REQUIRED') {
|
||||
setNeedsMfa(true)
|
||||
setError('Please enter your MFA code.')
|
||||
} else if (message === 'PASSWORD_RESET_REQUIRED') {
|
||||
setForcePasswordReset(true)
|
||||
setError('You must change your password before logging in.')
|
||||
} else if (message === 'ACCOUNT_LOCKED') {
|
||||
setError('Account locked due to too many failed login attempts. Please try again in 30 minutes.')
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
@ -92,6 +137,44 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleForceChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pwValid || pwMismatch) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authApi.forceChangePassword(username, password, newPassword)
|
||||
setPasswordChanged(true)
|
||||
setForcePasswordReset(false)
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
setPassword('')
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
|
||||
const code = axiosErr.response?.data?.error?.code
|
||||
const msg = axiosErr.response?.data?.error?.message
|
||||
if (code === 'weak_password') {
|
||||
setError(msg || 'Password does not meet strength requirements.')
|
||||
} else if (code === 'invalid_credentials') {
|
||||
setError('Invalid username or password.')
|
||||
} else {
|
||||
setError(msg || 'Failed to change password. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setForcePasswordReset(false)
|
||||
setPasswordChanged(false)
|
||||
setError(null)
|
||||
setPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
||||
<Paper elevation={4} sx={{ p: 4 }}>
|
||||
@ -101,7 +184,7 @@ export default function LoginPage() {
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity={needsMfa ? 'info' : 'error'}
|
||||
severity={forcePasswordReset ? 'warning' : 'error'}
|
||||
sx={{ mb: 2 }}
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
@ -109,42 +192,139 @@ export default function LoginPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Username" autoComplete="username"
|
||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{needsMfa && (
|
||||
{passwordChanged ? (
|
||||
<Box>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Password changed successfully! Please log in with your new password.
|
||||
</Alert>
|
||||
<Button
|
||||
fullWidth variant="contained" size="large"
|
||||
onClick={handleBackToLogin}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</Box>
|
||||
) : forcePasswordReset ? (
|
||||
<Box component="form" onSubmit={handleForceChangePassword} noValidate>
|
||||
<Typography variant="h6" fontWeight={600} mb={2}>
|
||||
Change Your Password
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
Your password has expired and must be changed before you can log in.
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
|
||||
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
|
||||
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
helperText="Enter the 6-digit code from your authenticator app"
|
||||
fullWidth margin="normal" label="Username"
|
||||
value={username} InputProps={{ readOnly: true }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained" size="large"
|
||||
sx={{ mt: 3 }} disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
||||
</Button>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Current Password" type="password"
|
||||
value={password} InputProps={{ readOnly: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="New Password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading} required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowNewPassword(!showNewPassword)} edge="end">
|
||||
{showNewPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{newPassword && (
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<List dense disablePadding>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Confirm New Password" type="password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
disabled={loading} required
|
||||
error={pwMismatch}
|
||||
helperText={pwMismatch ? 'Passwords do not match' : ''}
|
||||
/>
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained" size="large"
|
||||
sx={{ mt: 3 }} disabled={loading || !pwValid || pwMismatch}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Change Password'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Username" autoComplete="username"
|
||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{needsMfa && (
|
||||
<TextField
|
||||
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
|
||||
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
|
||||
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
|
||||
disabled={loading} required autoFocus
|
||||
helperText="Enter the 6-digit code from your authenticator app"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained" size="large"
|
||||
sx={{ mt: 3 }} disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@ -2,7 +2,10 @@ import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
|
||||
IconButton, Tooltip, Snackbar,
|
||||
} from '@mui/material'
|
||||
import { ContentCopy as CopyIcon } from '@mui/icons-material'
|
||||
import QRCode from 'qrcode'
|
||||
import { authApi } from '../api/client'
|
||||
|
||||
const STEPS = ['Get your QR code', 'Verify code', 'Done']
|
||||
@ -13,13 +16,35 @@ export default function MfaSetupPage() {
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getMfaSetup()
|
||||
.then((res) => setSetup(res.data))
|
||||
.then((res) => {
|
||||
setSetup(res.data)
|
||||
// Generate QR code from otpauth URI
|
||||
if (res.data.otp_uri) {
|
||||
QRCode.toDataURL(res.data.otp_uri, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
.then((url) => setQrDataUrl(url))
|
||||
.catch(() => setError('Failed to generate QR code.'))
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Failed to load MFA setup.'))
|
||||
}, [])
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (setup?.secret_base32) {
|
||||
navigator.clipboard.writeText(setup.secret_base32)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!setup) return
|
||||
@ -48,14 +73,46 @@ export default function MfaSetupPage() {
|
||||
{step === 0 && setup && (
|
||||
<Box>
|
||||
<Typography mb={2}>
|
||||
Scan this URI in your authenticator app or enter the secret manually:
|
||||
Scan this QR code in your authenticator app:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all', mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
|
||||
{setup.otp_uri}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
|
||||
Manual entry secret: <strong>{setup.secret_base32}</strong>
|
||||
{qrDataUrl ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="MFA QR Code"
|
||||
width={256}
|
||||
height={256}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
If you can't scan the QR code, enter the secret manually:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
p: 1,
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{setup.secret_base32}
|
||||
</Typography>
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
|
||||
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
|
||||
</Box>
|
||||
)}
|
||||
@ -81,6 +138,15 @@ export default function MfaSetupPage() {
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setCopied(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
Box, Button, Card, CardContent, Chip, Container, Dialog,
|
||||
DialogActions, DialogContent, DialogTitle, Snackbar,
|
||||
Alert, TextField, Typography, InputAdornment, IconButton,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Person as PersonIcon,
|
||||
@ -11,11 +12,27 @@ import {
|
||||
Visibility, VisibilityOff,
|
||||
VpnKey as MfaIcon,
|
||||
Save as SaveIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { usersApi } from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const { user, setUser } = useAuthStore()
|
||||
@ -49,6 +66,10 @@ export default function ProfilePage() {
|
||||
const showSnack = (severity: 'success' | 'error', message: string) =>
|
||||
setSnack({ open: true, severity, message })
|
||||
|
||||
const pwChecks = checkPasswordStrength(newPw)
|
||||
const pwValid = isPasswordValid(pwChecks)
|
||||
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
|
||||
|
||||
// ── Load current user on mount ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
@ -87,6 +108,10 @@ export default function ProfilePage() {
|
||||
showSnack('error', 'New passwords do not match')
|
||||
return
|
||||
}
|
||||
if (!pwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
setChangingPw(true)
|
||||
try {
|
||||
await usersApi.changePassword({ current_password: currentPw, new_password: newPw })
|
||||
@ -127,8 +152,6 @@ export default function ProfilePage() {
|
||||
)
|
||||
}
|
||||
|
||||
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ mt: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mb: 3 }}>My Profile</Typography>
|
||||
@ -217,6 +240,42 @@ export default function ProfilePage() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{newPw && (
|
||||
<Box sx={{ mt: -1, mb: 0 }}>
|
||||
<List dense disablePadding>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{pwChecks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
label="Confirm New Password"
|
||||
type={showConfirmPw ? 'text' : 'password'}
|
||||
@ -239,7 +298,7 @@ export default function ProfilePage() {
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleChangePassword}
|
||||
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch}
|
||||
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch || !pwValid}
|
||||
>
|
||||
{changingPw ? 'Changing…' : 'Change Password'}
|
||||
</Button>
|
||||
|
||||
@ -5,15 +5,74 @@ import {
|
||||
MenuItem, Paper, Select, Switch, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||
Snackbar, Alert, InputAdornment, FormControl, InputLabel,
|
||||
List, ListItem, ListItemIcon, ListItemText,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon, Lock as LockIcon, Edit as EditIcon,
|
||||
VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon,
|
||||
Check as CheckIcon, Close as CloseIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { usersApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types'
|
||||
|
||||
/** Password strength checker */
|
||||
function checkPasswordStrength(password: string) {
|
||||
return {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
digit: /[0-9]/.test(password),
|
||||
special: /[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password),
|
||||
}
|
||||
}
|
||||
|
||||
function isPasswordValid(checks: ReturnType<typeof checkPasswordStrength>) {
|
||||
return checks.length && checks.uppercase && checks.lowercase && checks.digit && checks.special
|
||||
}
|
||||
|
||||
/** Reusable password strength checklist component */
|
||||
function PasswordStrengthIndicator({ password }: { password: string }) {
|
||||
if (!password) return null
|
||||
const checks = checkPasswordStrength(password)
|
||||
return (
|
||||
<Box sx={{ mt: 0.5, mb: 1 }}>
|
||||
<List dense disablePadding>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.length ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least 8 characters" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.uppercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one uppercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.lowercase ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one lowercase letter" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.digit ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one digit" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
<ListItem disableGutters sx={{ py: 0 }}>
|
||||
<ListItemIcon sx={{ minWidth: 28 }}>
|
||||
{checks.special ? <CheckIcon color="success" fontSize="small" /> : <CloseIcon color="error" fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="At least one special character" primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
const isAdmin = currentUser?.role === 'admin'
|
||||
@ -56,6 +115,10 @@ export default function UsersPage() {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteUser, setDeleteUser] = useState<User | null>(null)
|
||||
|
||||
const addPwValid = isPasswordValid(checkPasswordStrength(addForm.password))
|
||||
const resetPwValid = isPasswordValid(checkPasswordStrength(resetForm.new_password))
|
||||
const resetPwMismatch = !!(resetForm.confirm_password && resetForm.new_password !== resetForm.confirm_password)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@ -90,6 +153,10 @@ export default function UsersPage() {
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!addPwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await usersApi.create(addForm)
|
||||
setAddOpen(false)
|
||||
@ -142,12 +209,12 @@ export default function UsersPage() {
|
||||
|
||||
const handleResetSave = async () => {
|
||||
if (!resetUser) return
|
||||
if (resetForm.new_password !== resetForm.confirm_password) {
|
||||
if (resetPwMismatch) {
|
||||
showSnack('error', 'Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (resetForm.new_password.length < 8) {
|
||||
showSnack('error', 'Password must be at least 8 characters')
|
||||
if (!resetPwValid) {
|
||||
showSnack('error', 'Password does not meet strength requirements')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@ -326,6 +393,7 @@ export default function UsersPage() {
|
||||
value={addForm.password}
|
||||
onChange={e => setAddForm({ ...addForm, password: e.target.value })}
|
||||
margin="normal" required />
|
||||
<PasswordStrengthIndicator password={addForm.password} />
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={addForm.role} label="Role"
|
||||
@ -338,7 +406,7 @@ export default function UsersPage() {
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}
|
||||
disabled={!addForm.username || !addForm.email || !addForm.password}>
|
||||
disabled={!addForm.username || !addForm.email || !addForm.password || !addPwValid}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@ -427,17 +495,14 @@ export default function UsersPage() {
|
||||
value={resetForm.new_password}
|
||||
onChange={e => setResetForm({ ...resetForm, new_password: e.target.value })}
|
||||
margin="normal" required
|
||||
error={resetForm.new_password.length > 0 && resetForm.new_password.length < 8}
|
||||
helperText={resetForm.new_password.length > 0 && resetForm.new_password.length < 8
|
||||
? 'Minimum 8 characters' : ''}
|
||||
/>
|
||||
<PasswordStrengthIndicator password={resetForm.new_password} />
|
||||
<TextField fullWidth label="Confirm Password" type="password"
|
||||
value={resetForm.confirm_password}
|
||||
onChange={e => setResetForm({ ...resetForm, confirm_password: e.target.value })}
|
||||
margin="normal" required
|
||||
error={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password}
|
||||
helperText={resetForm.confirm_password.length > 0 && resetForm.new_password !== resetForm.confirm_password
|
||||
? 'Passwords do not match' : ''}
|
||||
error={resetPwMismatch}
|
||||
helperText={resetPwMismatch ? 'Passwords do not match' : ''}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@ -453,8 +518,8 @@ export default function UsersPage() {
|
||||
<Button variant="contained" color="warning" onClick={handleResetSave}
|
||||
disabled={
|
||||
!resetForm.new_password ||
|
||||
resetForm.new_password.length < 8 ||
|
||||
resetForm.new_password !== resetForm.confirm_password
|
||||
!resetPwValid ||
|
||||
resetPwMismatch
|
||||
}>
|
||||
Reset Password
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user