diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 7398630..968d543 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,51 +1,237 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, - DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, - Table, TableBody, TableCell, TableContainer, TableHead, TableRow, - TextField, Toolbar, Tooltip, Typography, + DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton, + MenuItem, Paper, Select, Switch, Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, + Snackbar, Alert, InputAdornment, FormControl, InputLabel, } from '@mui/material' -import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material' -import { apiClient } from '../api/client' -import type { User } from '../types' +import { + Add as AddIcon, Lock as LockIcon, Edit as EditIcon, + VpnKey as VpnKeyIcon, Delete as DeleteIcon, Search as SearchIcon, +} from '@mui/icons-material' +import { usersApi } from '../api/client' +import { useAuthStore } from '../store/authStore' +import type { User, UpdateUserRequest, AdminResetPasswordRequest } from '../types' export default function UsersPage() { + const currentUser = useAuthStore(s => s.user) + const isAdmin = currentUser?.role === 'admin' + const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) - const [open, setOpen] = useState(false) - const [form, setForm] = useState({ username: '', email: '', role: 'operator', password: '' }) + + // Snackbar + 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 }) + + // Search / filter + const [searchText, setSearchText] = useState('') + const [roleFilter, setRoleFilter] = useState('all') + + // Add User dialog + const [addOpen, setAddOpen] = useState(false) + const [addForm, setAddForm] = useState({ username: '', display_name: '', email: '', role: 'operator', password: '' }) + + // Edit User dialog + const [editOpen, setEditOpen] = useState(false) + const [editUser, setEditUser] = useState(null) + const [editForm, setEditForm] = useState({ + display_name: '', email: '', role: 'operator', is_active: true, force_password_reset: false, + }) + + // Password Reset dialog + const [resetOpen, setResetOpen] = useState(false) + const [resetUser, setResetUser] = useState(null) + const [resetForm, setResetForm] = useState({ new_password: '', confirm_password: '', force_password_reset: true }) + + // MFA Disable confirmation dialog + const [mfaConfirmOpen, setMfaConfirmOpen] = useState(false) + const [mfaDisableUser, setMfaDisableUser] = useState(null) + + // Delete confirmation dialog + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleteUser, setDeleteUser] = useState(null) const load = async () => { setLoading(true) try { - const r = await apiClient.get('/users') + const r = await usersApi.list() setUsers(r.data) - } catch { /* interceptor handles */ } - finally { setLoading(false) } + } catch { + showSnack('error', 'Failed to load users') + } finally { + setLoading(false) + } } useEffect(() => { load() }, []) + // Filtered users + const filteredUsers = useMemo(() => { + let list = users + if (roleFilter !== 'all') { + list = list.filter(u => u.role === roleFilter) + } + if (searchText.trim()) { + const q = searchText.toLowerCase() + list = list.filter(u => + u.username.toLowerCase().includes(q) || + u.display_name.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) + ) + } + return list + }, [users, roleFilter, searchText]) + + // ── Handlers ──────────────────────────────────────────────────────────────── + const handleCreate = async () => { try { - await apiClient.post('/users', form) - setOpen(false) - setForm({ username: '', email: '', role: 'operator', password: '' }) + await usersApi.create(addForm) + setAddOpen(false) + setAddForm({ username: '', display_name: '', email: '', role: 'operator', password: '' }) + showSnack('success', 'User created successfully') load() - } catch { /* interceptor handles */ } + } catch { + showSnack('error', 'Failed to create user') + } } const handleRevoke = async (id: string) => { - await apiClient.post(`/users/${id}/revoke`) + try { + await usersApi.revokeSessions(id) + showSnack('success', 'Sessions revoked') + } catch { + showSnack('error', 'Failed to revoke sessions') + } } + const openEdit = (u: User) => { + setEditUser(u) + setEditForm({ + display_name: u.display_name || '', + email: u.email || '', + role: u.role, + is_active: u.is_active, + force_password_reset: u.force_password_reset, + }) + setEditOpen(true) + } + + const handleEditSave = async () => { + if (!editUser) return + try { + await usersApi.update(editUser.id, editForm) + setEditOpen(false) + showSnack('success', 'User updated successfully') + load() + } catch { + showSnack('error', 'Failed to update user') + } + } + + const openReset = (u: User) => { + setResetUser(u) + setResetForm({ new_password: '', confirm_password: '', force_password_reset: true }) + setResetOpen(true) + } + + const handleResetSave = async () => { + if (!resetUser) return + if (resetForm.new_password !== resetForm.confirm_password) { + showSnack('error', 'Passwords do not match') + return + } + if (resetForm.new_password.length < 8) { + showSnack('error', 'Password must be at least 8 characters') + return + } + try { + const data: AdminResetPasswordRequest = { + new_password: resetForm.new_password, + force_password_reset: resetForm.force_password_reset, + } + await usersApi.adminResetPassword(resetUser.id, data) + setResetOpen(false) + showSnack('success', 'Password reset successfully') + } catch { + showSnack('error', 'Failed to reset password') + } + } + + const handleMfaDisable = (u: User) => { + setMfaDisableUser(u) + setMfaConfirmOpen(true) + } + + const handleMfaDisableConfirm = async () => { + if (!mfaDisableUser) return + try { + await usersApi.adminDisableMfa(mfaDisableUser.id) + setMfaConfirmOpen(false) + showSnack('success', 'MFA disabled successfully') + load() + } catch { + showSnack('error', 'Failed to disable MFA') + } + } + + const openDelete = (u: User) => { + setDeleteUser(u) + setDeleteOpen(true) + } + + const handleDeleteConfirm = async () => { + if (!deleteUser) return + try { + await usersApi.delete(deleteUser.id) + setDeleteOpen(false) + showSnack('success', 'User deleted successfully') + load() + } catch { + showSnack('error', 'Failed to delete user') + } + } + + // ── Render ───────────────────────────────────────────────────────────────── + return ( Users - + {isAdmin && ( + + )} + {/* Search / Filter bar */} + + setSearchText(e.target.value)} + InputProps={{ + startAdornment: ( + + ), + }} + sx={{ flexGrow: 1 }} + /> + + Role + + + + {loading ? ( ) : ( @@ -54,6 +240,7 @@ export default function UsersPage() { Username + Display Name Email Role MFA @@ -62,9 +249,10 @@ export default function UsersPage() { - {users.map(u => ( + {filteredUsers.map(u => ( {u.username} + {u.display_name} {u.email} + + openEdit(u)}> + + + + {isAdmin && ( + + openReset(u)}> + + + + )} handleRevoke(u.id)}> + {isAdmin && ( + + openDelete(u)}> + + + + )} ))} + {filteredUsers.length === 0 && ( + + + No users found + + + )} )} - setOpen(false)} maxWidth="xs" fullWidth> + {/* ── Add User Dialog ──────────────────────────────────────────────────── */} + setAddOpen(false)} maxWidth="xs" fullWidth> Add User setForm({ ...form, username: e.target.value })} + value={addForm.username} + onChange={e => setAddForm({ ...addForm, username: e.target.value })} margin="normal" required /> + setAddForm({ ...addForm, display_name: e.target.value })} + margin="normal" /> setForm({ ...form, email: e.target.value })} + value={addForm.email} + onChange={e => setAddForm({ ...addForm, email: e.target.value })} margin="normal" required /> setForm({ ...form, password: e.target.value })} + value={addForm.password} + onChange={e => setAddForm({ ...addForm, password: e.target.value })} margin="normal" required /> - + + Role + + - + + + {/* ── Edit User Dialog ──────────────────────────────────────────────────── */} + setEditOpen(false)} maxWidth="xs" fullWidth> + Edit User + + + setEditForm({ ...editForm, display_name: e.target.value })} + margin="normal" /> + setEditForm({ ...editForm, email: e.target.value })} + margin="normal" /> + + {isAdmin && ( + <> + + Role + + + + setEditForm({ ...editForm, is_active: e.target.checked })} /> + } + label="Active" + /> + + + setEditForm({ ...editForm, force_password_reset: e.target.checked })} /> + } + label="Force Password Reset" + /> + + + {/* MFA status & disable */} + + MFA Status: + + {editUser?.mfa_enabled && ( + + )} + + {editUser?.mfa_enabled && ( + + Disabling MFA reduces account security for this user. + + )} + + )} + + + + + + + + {/* ── Admin Password Reset Dialog ─────────────────────────────────────── */} + setResetOpen(false)} maxWidth="xs" fullWidth> + Reset Password for {resetUser?.username} + + 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' : ''} + /> + 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' : ''} + /> + setResetForm({ ...resetForm, force_password_reset: e.target.checked })} /> + } + label="Force password reset on next login" + sx={{ mt: 1 }} + /> + + + + + + + + {/* ── MFA Disable Confirmation Dialog ──────────────────────────────────── */} + setMfaConfirmOpen(false)} maxWidth="xs" fullWidth> + Disable MFA + + + Are you sure you want to disable MFA for user {mfaDisableUser?.username}? + This will reduce the security of their account. + + + + + + + + + {/* ── Delete Confirmation Dialog ────────────────────────────────────────── */} + setDeleteOpen(false)} maxWidth="xs" fullWidth> + Delete User + + + Are you sure you want to delete user {deleteUser?.username}? + This action cannot be undone. + + + + + + + + + {/* ── Snackbar ──────────────────────────────────────────────────────────── */} + setSnack(s => ({ ...s, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnack(s => ({ ...s, open: false }))} + severity={snack.severity} + variant="filled" + sx={{ width: '100%' }} + > + {snack.message} + + ) }