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 { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user