Admin ui visual cleanup
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-25 23:15:58 -05:00
parent 9a913ab90b
commit 389424ee89
4 changed files with 195 additions and 49 deletions

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
} from '@mui/material';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
onConfirm: () => void;
onClose: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
open,
title,
message,
confirmLabel = 'Delete',
onConfirm,
onClose,
}) => (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Typography>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onConfirm} variant="contained" color="error">
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
);
export default ConfirmDialog;

View File

@@ -23,12 +23,15 @@ import { Permission, CreatePermissionRequest } from '../../../types';
import { adminService } from '../../../services/adminService'; import { adminService } from '../../../services/adminService';
import { useNotification } from '../../../context/NotificationContext'; import { useNotification } from '../../../context/NotificationContext';
import PermissionFormDialog from './PermissionFormDialog'; import PermissionFormDialog from './PermissionFormDialog';
import ConfirmDialog from '../ConfirmDialog';
const PermissionsTab: React.FC = () => { const PermissionsTab: React.FC = () => {
const [permissions, setPermissions] = useState<Permission[]>([]); const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Permission | null>(null); const [editing, setEditing] = useState<Permission | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -65,12 +68,19 @@ const PermissionsTab: React.FC = () => {
load(); load();
}; };
const handleDelete = async (id: number) => { const requestDelete = (id: number) => {
if (!confirm('Delete this permission? It will be removed from all users and applications.')) return; setPendingDeleteId(id);
const res = await adminService.deletePermission(id); setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (pendingDeleteId === null) return;
setConfirmOpen(false);
const res = await adminService.deletePermission(pendingDeleteId);
if (res.error) { showNotification(res.error, 'error'); return; } if (res.error) { showNotification(res.error, 'error'); return; }
showNotification('Permission deleted', 'success'); showNotification('Permission deleted', 'success');
setPermissions(prev => prev.filter(p => p.id !== id)); setPermissions(prev => prev.filter(p => p.id !== pendingDeleteId));
setPendingDeleteId(null);
}; };
const handleToggleDefault = async (p: Permission) => { const handleToggleDefault = async (p: Permission) => {
@@ -128,7 +138,7 @@ const PermissionsTab: React.FC = () => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton size="small" color="error" onClick={() => handleDelete(p.id)}> <IconButton size="small" color="error" onClick={() => requestDelete(p.id)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -151,6 +161,14 @@ const PermissionsTab: React.FC = () => {
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
onSave={handleSave} onSave={handleSave}
/> />
<ConfirmDialog
open={confirmOpen}
title="Delete Permission"
message="Delete this permission? It will be removed from all users and applications."
onConfirm={handleConfirmDelete}
onClose={() => setConfirmOpen(false)}
/>
</Box> </Box>
); );
}; };

View File

@@ -21,12 +21,15 @@ import { RateLimit, CreateRateLimitRequest } from '../../../types';
import { adminService } from '../../../services/adminService'; import { adminService } from '../../../services/adminService';
import { useNotification } from '../../../context/NotificationContext'; import { useNotification } from '../../../context/NotificationContext';
import RateLimitFormDialog from './RateLimitFormDialog'; import RateLimitFormDialog from './RateLimitFormDialog';
import ConfirmDialog from '../ConfirmDialog';
const RateLimitsTab: React.FC = () => { const RateLimitsTab: React.FC = () => {
const [rateLimits, setRateLimits] = useState<RateLimit[]>([]); const [rateLimits, setRateLimits] = useState<RateLimit[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<RateLimit | null>(null); const [editing, setEditing] = useState<RateLimit | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -63,12 +66,19 @@ const RateLimitsTab: React.FC = () => {
load(); load();
}; };
const handleDelete = async (id: number) => { const requestDelete = (id: number) => {
if (!confirm('Delete this rate limit class? Users assigned to it may be affected.')) return; setPendingDeleteId(id);
const res = await adminService.deleteRateLimit(id); setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (pendingDeleteId === null) return;
setConfirmOpen(false);
const res = await adminService.deleteRateLimit(pendingDeleteId);
if (res.error) { showNotification(res.error, 'error'); return; } if (res.error) { showNotification(res.error, 'error'); return; }
showNotification('Rate limit deleted', 'success'); showNotification('Rate limit deleted', 'success');
setRateLimits(prev => prev.filter(r => r.id !== id)); setRateLimits(prev => prev.filter(r => r.id !== pendingDeleteId));
setPendingDeleteId(null);
}; };
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
@@ -109,7 +119,7 @@ const RateLimitsTab: React.FC = () => {
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton size="small" color="error" onClick={() => handleDelete(rl.id)}> <IconButton size="small" color="error" onClick={() => requestDelete(rl.id)}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -132,6 +142,14 @@ const RateLimitsTab: React.FC = () => {
onClose={() => setDialogOpen(false)} onClose={() => setDialogOpen(false)}
onSave={handleSave} onSave={handleSave}
/> />
<ConfirmDialog
open={confirmOpen}
title="Delete Rate Limit"
message="Delete this rate limit class? Users assigned to it may be affected."
onConfirm={handleConfirmDelete}
onClose={() => setConfirmOpen(false)}
/>
</Box> </Box>
); );
}; };

View File

@@ -25,7 +25,15 @@ import {
LinearProgress, LinearProgress,
CircularProgress, CircularProgress,
Stack, Stack,
Accordion,
AccordionSummary,
AccordionDetails,
Paper,
Grid,
} from '@mui/material'; } from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material';
import { AdminUser, RateLimit, Permission, Application } from '../../../types'; import { AdminUser, RateLimit, Permission, Application } from '../../../types';
import { adminService } from '../../../services/adminService'; import { adminService } from '../../../services/adminService';
import { useNotification } from '../../../context/NotificationContext'; import { useNotification } from '../../../context/NotificationContext';
@@ -51,6 +59,16 @@ const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
</Box> </Box>
); );
// Group permissions by service, same pattern as ApplicationFormDialog
function groupByService(permissions: Permission[]): Map<string, Permission[]> {
const map = new Map<string, Permission[]>();
for (const p of permissions) {
if (!map.has(p.service)) map.set(p.service, []);
map.get(p.service)!.push(p);
}
return map;
}
const UserDetailDialog: React.FC<UserDetailDialogProps> = ({ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
open, user, rateLimits, allPermissions, onClose, onUserUpdated open, user, rateLimits, allPermissions, onClose, onUserUpdated
}) => { }) => {
@@ -70,6 +88,7 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
setUserPerms(new Set(user.permissions.map(p => p.id))); setUserPerms(new Set(user.permissions.map(p => p.id)));
} }
setTab(0); setTab(0);
setApplications([]);
}, [user, open]); }, [user, open]);
useEffect(() => { useEffect(() => {
@@ -95,16 +114,17 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
onUserUpdated(); onUserUpdated();
}; };
const handleTogglePermission = async (permId: number, checked: boolean) => { const handleTogglePermission = async (perm: Permission, checked: boolean) => {
if (!user) return; if (!user) return;
if (perm.is_default) return; // default perms cannot be toggled
if (checked) { if (checked) {
const res = await adminService.addUserPermission(user.id, permId); const res = await adminService.addUserPermission(user.id, perm.id);
if (res.error) { showNotification(res.error, 'error'); return; } if (res.error) { showNotification(res.error, 'error'); return; }
setUserPerms(prev => new Set([...prev, permId])); setUserPerms(prev => new Set([...prev, perm.id]));
} else { } else {
const res = await adminService.removeUserPermission(user.id, permId); const res = await adminService.removeUserPermission(user.id, perm.id);
if (res.error) { showNotification(res.error, 'error'); return; } if (res.error) { showNotification(res.error, 'error'); return; }
setUserPerms(prev => { const s = new Set(prev); s.delete(permId); return s; }); setUserPerms(prev => { const s = new Set(prev); s.delete(perm.id); return s; });
} }
}; };
@@ -120,6 +140,8 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
if (!user) return null; if (!user) return null;
const serviceMap = groupByService(allPermissions);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle> <DialogTitle>
@@ -239,43 +261,89 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
</Stack> </Stack>
</TabPanel> </TabPanel>
{/* Permissions */} {/* Permissions — grouped by service, same layout as ApplicationFormDialog */}
<TabPanel value={tab} index={3}> <TabPanel value={tab} index={3}>
<Table size="small"> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<TableHead> Permissions are grouped by service. Default permissions cannot be removed.
<TableRow> </Typography>
<TableCell padding="checkbox" /> {Array.from(serviceMap.entries()).map(([service, perms]) => {
<TableCell>Service</TableCell> const grantedCount = perms.filter(p => userPerms.has(p.id)).length;
<TableCell>Permission</TableCell> return (
<TableCell>Title</TableCell> <Accordion
<TableCell>Default</TableCell> key={service}
</TableRow> sx={{
</TableHead> mb: 2,
<TableBody> border: '1px solid rgba(255, 255, 255, 0.12)',
{allPermissions.map(p => ( borderRadius: '4px',
<TableRow key={p.id} hover> '&:before': { display: 'none' },
<TableCell padding="checkbox"> }}
<Checkbox >
checked={userPerms.has(p.id)} <AccordionSummary
disabled={p.is_default} expandIcon={<ExpandMoreIcon />}
onChange={e => handleTogglePermission(p.id, e.target.checked)} sx={{
borderBottom: '1px solid rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ flexGrow: 1 }}>{service} API</Typography>
<Chip
label={`${grantedCount}/${perms.length}`}
size="small" size="small"
color={grantedCount > 0 ? 'primary' : 'default'}
sx={{ mr: 2 }}
/> />
</TableCell> </Box>
<TableCell> </AccordionSummary>
<Chip label={p.service} size="small" variant="outlined" /> <AccordionDetails>
</TableCell> <Grid container spacing={2}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}> {perms.map(p => (
{p.permission_name} <Grid size={{ xs: 12, md: 6 }} key={p.id}>
</TableCell> <Paper
<TableCell>{p.title}</TableCell> variant="outlined"
<TableCell> sx={{
{p.is_default && <Chip label="default" size="small" color="info" />} p: 1,
</TableCell> backgroundColor: 'rgba(255, 255, 255, 0.03)',
</TableRow> borderColor: 'rgba(255, 255, 255, 0.12)',
))} '&:hover': {
</TableBody> backgroundColor: 'rgba(255, 255, 255, 0.05)',
</Table> },
}}
>
<FormControlLabel
control={
<Checkbox
checked={userPerms.has(p.id)}
disabled={p.is_default}
onChange={e => handleTogglePermission(p, e.target.checked)}
size="small"
/>
}
label={
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2">{p.permission_name}</Typography>
{p.is_default && (
<Chip label="default" size="small" color="info" sx={{ height: 16, fontSize: '0.65rem' }} />
)}
</Box>
<Typography variant="caption" color="text.secondary">
{p.description || p.title}
</Typography>
</Box>
}
/>
</Paper>
</Grid>
))}
</Grid>
</AccordionDetails>
</Accordion>
);
})}
{allPermissions.length === 0 && (
<Typography color="text.secondary">No permissions defined.</Typography>
)}
</TabPanel> </TabPanel>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>