feat: Phase 3 - admin user management with edit, password reset, MFA disable, search/filter
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 4s
CI Pipeline / Clippy Lints (push) Successful in 47s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
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 4s
CI Pipeline / Clippy Lints (push) Successful in 47s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
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:
@ -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<User[]>([])
|
||||
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<User | null>(null)
|
||||
const [editForm, setEditForm] = useState<UpdateUserRequest & { display_name: string; email: string; role: string; is_active: boolean; force_password_reset: boolean }>({
|
||||
display_name: '', email: '', role: 'operator', is_active: true, force_password_reset: false,
|
||||
})
|
||||
|
||||
// Password Reset dialog
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const [resetUser, setResetUser] = useState<User | null>(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<User | null>(null)
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteUser, setDeleteUser] = useState<User | null>(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 (
|
||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button>
|
||||
{isAdmin && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add User</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
{/* Search / Filter bar */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search by username, name, or email…"
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={roleFilter} label="Role" onChange={e => setRoleFilter(e.target.value)}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
|
||||
) : (
|
||||
@ -54,6 +240,7 @@ export default function UsersPage() {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Display Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>MFA</TableCell>
|
||||
@ -62,9 +249,10 @@ export default function UsersPage() {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map(u => (
|
||||
{filteredUsers.map(u => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{u.display_name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={u.role}
|
||||
@ -79,49 +267,250 @@ export default function UsersPage() {
|
||||
color={u.is_active ? 'success' : 'error'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title="Edit User">
|
||||
<IconButton size="small" color="primary" onClick={() => openEdit(u)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Reset Password">
|
||||
<IconButton size="small" color="warning" onClick={() => openReset(u)}>
|
||||
<VpnKeyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Revoke All Sessions">
|
||||
<IconButton size="small" color="warning" onClick={() => handleRevoke(u.id)}>
|
||||
<LockIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip title="Delete User">
|
||||
<IconButton size="small" color="error" onClick={() => openDelete(u)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ py: 3, color: 'text.secondary' }}>
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
|
||||
{/* ── Add User Dialog ──────────────────────────────────────────────────── */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Username"
|
||||
value={form.username}
|
||||
onChange={e => setForm({ ...form, username: e.target.value })}
|
||||
value={addForm.username}
|
||||
onChange={e => setAddForm({ ...addForm, username: e.target.value })}
|
||||
margin="normal" required />
|
||||
<TextField fullWidth label="Display Name"
|
||||
value={addForm.display_name}
|
||||
onChange={e => setAddForm({ ...addForm, display_name: e.target.value })}
|
||||
margin="normal" />
|
||||
<TextField fullWidth label="Email" type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
value={addForm.email}
|
||||
onChange={e => setAddForm({ ...addForm, email: e.target.value })}
|
||||
margin="normal" required />
|
||||
<TextField fullWidth label="Password" type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm({ ...form, password: e.target.value })}
|
||||
value={addForm.password}
|
||||
onChange={e => setAddForm({ ...addForm, password: e.target.value })}
|
||||
margin="normal" required />
|
||||
<Select fullWidth value={form.role}
|
||||
onChange={e => setForm({ ...form, role: e.target.value })}
|
||||
sx={{ mt: 1 }}>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
</Select>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={addForm.role} label="Role"
|
||||
onChange={e => setAddForm({ ...addForm, role: e.target.value })}>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate}
|
||||
disabled={!form.username || !form.email || !form.password}>
|
||||
disabled={!addForm.username || !addForm.email || !addForm.password}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Edit User Dialog ──────────────────────────────────────────────────── */}
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="Username"
|
||||
value={editUser?.username ?? ''}
|
||||
margin="normal" slotProps={{ input: { readOnly: true } }}
|
||||
helperText="Username cannot be changed"
|
||||
/>
|
||||
<TextField fullWidth label="Display Name"
|
||||
value={editForm.display_name}
|
||||
onChange={e => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||
margin="normal" />
|
||||
<TextField fullWidth label="Email" type="email"
|
||||
value={editForm.email}
|
||||
onChange={e => setEditForm({ ...editForm, email: e.target.value })}
|
||||
margin="normal" />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select value={editForm.role} label="Role"
|
||||
onChange={e => setEditForm({ ...editForm, role: e.target.value })}>
|
||||
<MenuItem value="operator">Operator</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={editForm.is_active}
|
||||
onChange={e => setEditForm({ ...editForm, is_active: e.target.checked })} />
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={editForm.force_password_reset}
|
||||
onChange={e => setEditForm({ ...editForm, force_password_reset: e.target.checked })} />
|
||||
}
|
||||
label="Force Password Reset"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* MFA status & disable */}
|
||||
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">MFA Status:</Typography>
|
||||
<Chip size="small"
|
||||
label={editUser?.mfa_enabled ? 'Enabled' : 'Disabled'}
|
||||
color={editUser?.mfa_enabled ? 'success' : 'default'}
|
||||
/>
|
||||
{editUser?.mfa_enabled && (
|
||||
<Button size="small" color="error" variant="outlined"
|
||||
onClick={() => editUser && handleMfaDisable(editUser)}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{editUser?.mfa_enabled && (
|
||||
<Typography variant="caption" color="warning.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||
Disabling MFA reduces account security for this user.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleEditSave}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Admin Password Reset Dialog ─────────────────────────────────────── */}
|
||||
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Reset Password for {resetUser?.username}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField fullWidth label="New Password" type="password"
|
||||
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' : ''}
|
||||
/>
|
||||
<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' : ''}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={resetForm.force_password_reset}
|
||||
onChange={e => setResetForm({ ...resetForm, force_password_reset: e.target.checked })} />
|
||||
}
|
||||
label="Force password reset on next login"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResetOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="warning" onClick={handleResetSave}
|
||||
disabled={
|
||||
!resetForm.new_password ||
|
||||
resetForm.new_password.length < 8 ||
|
||||
resetForm.new_password !== resetForm.confirm_password
|
||||
}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── MFA Disable Confirmation Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={mfaConfirmOpen} onClose={() => setMfaConfirmOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Disable MFA</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to disable MFA for user <strong>{mfaDisableUser?.username}</strong>?
|
||||
This will reduce the security of their account.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMfaConfirmOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleMfaDisableConfirm}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation Dialog ────────────────────────────────────────── */}
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete user <strong>{deleteUser?.username}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDeleteConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Snackbar ──────────────────────────────────────────────────────────── */}
|
||||
<Snackbar
|
||||
open={snack.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnack(s => ({ ...s, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnack(s => ({ ...s, open: false }))}
|
||||
severity={snack.severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snack.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user