Private
Public Access
1
0

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

This commit is contained in:
2026-05-07 17:53:16 +00:00
parent b5b975e7e5
commit cc1214a963
13 changed files with 889 additions and 68 deletions

View File

@ -101,6 +101,9 @@ export const authApi = {
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
forceChangePassword: (username: string, currentPassword: string, newPassword: string) =>
apiClient.post('/auth/force-change-password', { username, current_password: currentPassword, new_password: newPassword }),
getMfaSetup: () =>
apiClient.get('/auth/mfa/setup'),

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>