diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85d38e9..084895e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index e98fa31..b650d79 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -189,6 +189,10 @@ export default function AppLayout() { + { handleMenuClose(); navigate('/profile') }}> + + + diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..c018a5b --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -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(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 ( + + Loading profile… + + ) + } + + const pwMismatch = !!(newPw && confirmPw && newPw !== confirmPw) + + return ( + + My Profile + + {/* ── Profile Section ──────────────────────────────────────────────── */} + + + + Profile Information + + + + setDisplayName(e.target.value)} + /> + setEmail(e.target.value)} + /> + + Role: + + + + + + + + + + {/* ── Password Section ─────────────────────────────────────────────── */} + + + + Change Password + + + setCurrentPw(e.target.value)} + InputProps={{ + endAdornment: ( + + setShowCurrentPw(!showCurrentPw)} edge="end"> + {showCurrentPw ? : } + + + ), + }} + /> + setNewPw(e.target.value)} + error={pwMismatch} + InputProps={{ + endAdornment: ( + + setShowNewPw(!showNewPw)} edge="end"> + {showNewPw ? : } + + + ), + }} + /> + setConfirmPw(e.target.value)} + error={pwMismatch} + helperText={pwMismatch ? 'Passwords do not match' : ''} + InputProps={{ + endAdornment: ( + + setShowConfirmPw(!showConfirmPw)} edge="end"> + {showConfirmPw ? : } + + + ), + }} + /> + + + + + + + + {/* ── MFA Section ──────────────────────────────────────────────────── */} + + + + Multi-Factor Authentication + + + Status: + + + {me?.mfa_enabled ? ( + + ) : ( + + )} + + + + {/* ── Disable MFA Confirmation Dialog ─────────────────────────────── */} + setMfaDisableOpen(false)} maxWidth="xs" fullWidth> + Disable MFA + + + Are you sure you want to disable multi-factor authentication? This will make your account less secure. + + setMfaDisablePw(e.target.value)} + autoFocus + /> + + + + + + + + {/* ── Snackbar ─────────────────────────────────────────────────────── */} + setSnack((s) => ({ ...s, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnack((s) => ({ ...s, open: false }))} + variant="filled" + > + {snack.message} + + + + ) +}