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
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:
@ -17,6 +17,7 @@ import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
|
||||
import CertificatesPage from './pages/CertificatesPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
@ -104,6 +105,7 @@ function App() {
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/certificates" element={<CertificatesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@ -189,6 +189,10 @@ export default function AppLayout() {
|
||||
<ListItemText primary={user?.display_name || user?.username} secondary={user?.role} />
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { handleMenuClose(); navigate('/profile') }}>
|
||||
<ListItemIcon><PersonIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary="My Profile" />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText primary="Sign out" />
|
||||
|
||||
330
frontend/src/pages/ProfilePage.tsx
Normal file
330
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user