Private
Public Access
1
0

feat: Phase 2 - user profile page with self-service password change and MFA management
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 5s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 9s
CI Pipeline / Build .deb & Release (push) Has been skipped

This commit is contained in:
2026-05-07 16:32:55 +00:00
parent 0a70afbbe9
commit 5cf3125a2e
3 changed files with 336 additions and 0 deletions

View File

@ -17,6 +17,7 @@ import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
import CertificatesPage from './pages/CertificatesPage' import CertificatesPage from './pages/CertificatesPage'
import ReportsPage from './pages/ReportsPage' import ReportsPage from './pages/ReportsPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import ProfilePage from './pages/ProfilePage'
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
@ -104,6 +105,7 @@ function App() {
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/certificates" element={<CertificatesPage />} /> <Route path="/certificates" element={<CertificatesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />

View File

@ -189,6 +189,10 @@ export default function AppLayout() {
<ListItemText primary={user?.display_name || user?.username} secondary={user?.role} /> <ListItemText primary={user?.display_name || user?.username} secondary={user?.role} />
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem onClick={() => { handleMenuClose(); navigate('/profile') }}>
<ListItemIcon><PersonIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="My Profile" />
</MenuItem>
<MenuItem onClick={handleLogout}> <MenuItem onClick={handleLogout}>
<ListItemIcon><LogoutIcon fontSize="small" /></ListItemIcon> <ListItemIcon><LogoutIcon fontSize="small" /></ListItemIcon>
<ListItemText primary="Sign out" /> <ListItemText primary="Sign out" />

View File

@ -0,0 +1,330 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box, Button, Card, CardContent, Chip, Container, Dialog,
DialogActions, DialogContent, DialogTitle, Snackbar,
Alert, TextField, Typography, InputAdornment, IconButton,
} from '@mui/material'
import {
Person as PersonIcon,
Lock as LockIcon,
Visibility, VisibilityOff,
VpnKey as MfaIcon,
Save as SaveIcon,
} from '@mui/icons-material'
import { useAuthStore } from '../store/authStore'
import { usersApi } from '../api/client'
import type { User } from '../types'
export default function ProfilePage() {
const navigate = useNavigate()
const { user, setUser } = useAuthStore()
// ── Profile state ────────────────────────────────────────────────────────
const [me, setMe] = useState<User | null>(null)
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [loadingProfile, setLoadingProfile] = useState(true)
const [savingProfile, setSavingProfile] = useState(false)
// ── Password state ──────────────────────────────────────────────────────
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [showCurrentPw, setShowCurrentPw] = useState(false)
const [showNewPw, setShowNewPw] = useState(false)
const [showConfirmPw, setShowConfirmPw] = useState(false)
const [changingPw, setChangingPw] = useState(false)
// ── MFA state ────────────────────────────────────────────────────────────
const [mfaDisableOpen, setMfaDisableOpen] = useState(false)
const [mfaDisablePw, setMfaDisablePw] = useState('')
const [disablingMfa, setDisablingMfa] = useState(false)
// ── Snackbar state ──────────────────────────────────────────────────────
const [snack, setSnack] = useState<{ open: boolean; severity: 'success' | 'error'; message: string }>({
open: false, severity: 'success', message: '',
})
const showSnack = (severity: 'success' | 'error', message: string) =>
setSnack({ open: true, severity, message })
// ── Load current user on mount ──────────────────────────────────────────
useEffect(() => {
;(async () => {
try {
const { data } = await usersApi.getMe()
setMe(data)
setDisplayName(data.display_name || '')
setEmail(data.email || '')
} catch {
showSnack('error', 'Failed to load profile')
} finally {
setLoadingProfile(false)
}
})()
}, [])
// ── Save profile ────────────────────────────────────────────────────────
const handleSaveProfile = async () => {
if (!me) return
setSavingProfile(true)
try {
const { data } = await usersApi.update(me.id, { display_name: displayName, email })
setMe(data)
setUser(data)
showSnack('success', 'Profile updated')
} catch {
showSnack('error', 'Failed to update profile')
} finally {
setSavingProfile(false)
}
}
// ── Change password ────────────────────────────────────────────────────
const handleChangePassword = async () => {
if (newPw !== confirmPw) {
showSnack('error', 'New passwords do not match')
return
}
setChangingPw(true)
try {
await usersApi.changePassword({ current_password: currentPw, new_password: newPw })
setCurrentPw('')
setNewPw('')
setConfirmPw('')
showSnack('success', 'Password changed successfully')
} catch {
showSnack('error', 'Failed to change password')
} finally {
setChangingPw(false)
}
}
// ── Disable MFA ─────────────────────────────────────────────────────────
const handleDisableMfa = async () => {
setDisablingMfa(true)
try {
await usersApi.disableMfa(mfaDisablePw)
if (me) setMe({ ...me, mfa_enabled: false })
// Also update authStore user
if (user) setUser({ ...user, mfa_enabled: false })
setMfaDisablePw('')
setMfaDisableOpen(false)
showSnack('success', 'MFA disabled')
} catch {
showSnack('error', 'Failed to disable MFA')
} finally {
setDisablingMfa(false)
}
}
if (loadingProfile) {
return (
<Container maxWidth="md" sx={{ mt: 3 }}>
<Box display="flex" justifyContent="center" mt={4}>Loading profile</Box>
</Container>
)
}
const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw)
return (
<Container maxWidth="md" sx={{ mt: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ mb: 3 }}>My Profile</Typography>
{/* ── Profile Section ──────────────────────────────────────────────── */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon fontSize="small" /> Profile Information
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Username"
value={me?.username || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input.Mui-readOnly': { color: 'text.disabled' } }}
/>
<TextField
label="Display Name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary">Role:</Typography>
<Chip
size="small"
label={me?.role || 'unknown'}
color={me?.role === 'admin' ? 'primary' : 'default'}
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveProfile}
disabled={savingProfile}
>
{savingProfile ? 'Saving…' : 'Save Profile'}
</Button>
</Box>
</CardContent>
</Card>
{/* ── Password Section ─────────────────────────────────────────────── */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<LockIcon fontSize="small" /> Change Password
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Current Password"
type={showCurrentPw ? 'text' : 'password'}
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowCurrentPw(!showCurrentPw)} edge="end">
{showCurrentPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
label="New Password"
type={showNewPw ? 'text' : 'password'}
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
error={pwMismatch}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowNewPw(!showNewPw)} edge="end">
{showNewPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
label="Confirm New Password"
type={showConfirmPw ? 'text' : 'password'}
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
error={pwMismatch}
helperText={pwMismatch ? 'Passwords do not match' : ''}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowConfirmPw(!showConfirmPw)} edge="end">
{showConfirmPw ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={handleChangePassword}
disabled={changingPw || !currentPw || !newPw || !confirmPw || !!pwMismatch}
>
{changingPw ? 'Changing…' : 'Change Password'}
</Button>
</Box>
</CardContent>
</Card>
{/* ── MFA Section ──────────────────────────────────────────────────── */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" fontWeight={600} sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<MfaIcon fontSize="small" /> Multi-Factor Authentication
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="body2" color="text.secondary">Status:</Typography>
<Chip
size="small"
label={me?.mfa_enabled ? 'Enabled' : 'Disabled'}
color={me?.mfa_enabled ? 'success' : 'warning'}
/>
</Box>
{me?.mfa_enabled ? (
<Button
variant="outlined"
color="warning"
onClick={() => setMfaDisableOpen(true)}
>
Disable MFA
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={() => navigate('/mfa/setup')}
>
Enable MFA
</Button>
)}
</CardContent>
</Card>
{/* ── Disable MFA Confirmation Dialog ─────────────────────────────── */}
<Dialog open={mfaDisableOpen} onClose={() => setMfaDisableOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Disable MFA</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2 }}>
Are you sure you want to disable multi-factor authentication? This will make your account less secure.
</Typography>
<TextField
fullWidth
label="Enter your password to confirm"
type="password"
value={mfaDisablePw}
onChange={(e) => setMfaDisablePw(e.target.value)}
autoFocus
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setMfaDisableOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="warning"
onClick={handleDisableMfa}
disabled={disablingMfa || !mfaDisablePw}
>
{disablingMfa ? 'Disabling…' : 'Disable MFA'}
</Button>
</DialogActions>
</Dialog>
{/* ── Snackbar ─────────────────────────────────────────────────────── */}
<Snackbar
open={snack.open}
autoHideDuration={4000}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snack.severity}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
variant="filled"
>
{snack.message}
</Alert>
</Snackbar>
</Container>
)
}