Admin ui visual cleanup
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
42
web/src/components/admin/ConfirmDialog.tsx
Normal file
42
web/src/components/admin/ConfirmDialog.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user