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

View File

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

View File

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