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

This commit is contained in:
2026-02-27 09:45:37 -05:00
parent 4cfa2c7fff
commit b572c87818
7 changed files with 445 additions and 250 deletions

View File

@@ -53,9 +53,16 @@ const AdminPage: React.FC = () => {
maxWidth="xl"
sx={{ mt: 4, mb: 4, flexGrow: 1, width: '100%', maxWidth: '100% !important' }}
>
<Box sx={{ mb: 3 }}>
<Typography variant="h4" gutterBottom>Admin Panel</Typography>
<Typography variant="body1" color="text.secondary">
<Box
sx={{
mb: 3,
pl: 2,
borderLeft: '3px solid',
borderColor: 'primary.main',
}}
>
<Typography variant="h5" fontWeight={600} gutterBottom>Admin Panel</Typography>
<Typography variant="body2" color="text.secondary">
Manage users, permissions, and rate limit classes.
</Typography>
</Box>

View File

@@ -9,6 +9,7 @@ import {
Stack,
FormControlLabel,
Switch,
CircularProgress,
} from '@mui/material';
import { Permission, CreatePermissionRequest } from '../../../types';
@@ -27,9 +28,12 @@ const emptyForm: CreatePermissionRequest = {
is_default: false,
};
type FieldErrors = Partial<Record<keyof CreatePermissionRequest, string>>;
const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permission, onClose, onSave }) => {
const [form, setForm] = useState<CreatePermissionRequest>(emptyForm);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
useEffect(() => {
if (permission) {
@@ -43,13 +47,25 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
} else {
setForm(emptyForm);
}
setErrors({});
}, [permission, open]);
const handleField = (field: keyof CreatePermissionRequest) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }));
};
const validate = (): boolean => {
const next: FieldErrors = {};
if (!form.service.trim()) next.service = 'Service is required';
if (!form.permission_name.trim()) next.permission_name = 'Permission name is required';
if (!form.title.trim()) next.title = 'Title is required';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSave = async () => {
if (!validate()) return;
setSaving(true);
try {
await onSave(form);
@@ -59,7 +75,7 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>{permission ? 'Edit Permission' : 'Create Permission'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -69,6 +85,8 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
onChange={handleField('service')}
fullWidth
required
error={!!errors.service}
helperText={errors.service}
/>
<TextField
label="Permission Name"
@@ -76,6 +94,9 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
onChange={handleField('permission_name')}
fullWidth
required
error={!!errors.permission_name}
helperText={errors.permission_name}
slotProps={{ input: { sx: { fontFamily: 'monospace' } } }}
/>
<TextField
label="Title"
@@ -83,6 +104,8 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
onChange={handleField('title')}
fullWidth
required
error={!!errors.title}
helperText={errors.title}
/>
<TextField
label="Description"
@@ -105,7 +128,12 @@ const PermissionFormDialog: React.FC<PermissionFormDialogProps> = ({ open, permi
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>

View File

@@ -7,12 +7,16 @@ import {
TableBody,
TableRow,
TableCell,
TableContainer,
IconButton,
Typography,
Tooltip,
Chip,
Switch,
CircularProgress,
Paper,
Skeleton,
LinearProgress,
} from '@mui/material';
import {
Add as AddIcon,
@@ -25,6 +29,8 @@ import { useNotification } from '../../../context/NotificationContext';
import PermissionFormDialog from './PermissionFormDialog';
import ConfirmDialog from '../ConfirmDialog';
const SKELETON_ROWS = 4;
const PermissionsTab: React.FC = () => {
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(true);
@@ -32,6 +38,7 @@ const PermissionsTab: React.FC = () => {
const [editing, setEditing] = useState<Permission | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const [togglingDefaultId, setTogglingDefaultId] = useState<number | null>(null);
const { showNotification } = useNotification();
const load = useCallback(async () => {
@@ -84,22 +91,35 @@ const PermissionsTab: React.FC = () => {
};
const handleToggleDefault = async (p: Permission) => {
await adminService.setPermissionDefault(p.id, !p.is_default);
setTogglingDefaultId(p.id);
const res = await adminService.setPermissionDefault(p.id, !p.is_default);
if (res.error) {
showNotification(res.error, 'error');
} else {
setPermissions(prev => prev.map(item =>
item.id === p.id ? { ...item, is_default: !item.is_default } : item
));
}
setTogglingDefaultId(null);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Permissions</Typography>
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate}>
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate} disabled={loading}>
New Permission
</Button>
</Box>
<TableContainer
component={Paper}
variant="outlined"
sx={{ position: 'relative', overflow: 'hidden' }}
>
{loading && permissions.length > 0 && (
<LinearProgress sx={{ position: 'absolute', top: 0, left: 0, right: 0 }} />
)}
<Table size="small">
<TableHead>
<TableRow>
@@ -112,7 +132,18 @@ const PermissionsTab: React.FC = () => {
</TableRow>
</TableHead>
<TableBody>
{permissions.map(p => (
{loading && permissions.length === 0
? Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton width={24} /></TableCell>
<TableCell><Skeleton width={70} /></TableCell>
<TableCell><Skeleton width={160} /></TableCell>
<TableCell><Skeleton width={120} /></TableCell>
<TableCell><Skeleton width={40} /></TableCell>
<TableCell align="right"><Skeleton width={60} sx={{ ml: 'auto' }} /></TableCell>
</TableRow>
))
: permissions.map(p => (
<TableRow key={p.id} hover>
<TableCell>{p.id}</TableCell>
<TableCell>
@@ -123,6 +154,9 @@ const PermissionsTab: React.FC = () => {
</TableCell>
<TableCell>{p.title}</TableCell>
<TableCell>
{togglingDefaultId === p.id ? (
<CircularProgress size={18} sx={{ ml: 0.5 }} />
) : (
<Tooltip title={p.is_default ? 'Remove default' : 'Set as default'}>
<Switch
size="small"
@@ -130,6 +164,7 @@ const PermissionsTab: React.FC = () => {
onChange={() => handleToggleDefault(p)}
/>
</Tooltip>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
@@ -145,7 +180,7 @@ const PermissionsTab: React.FC = () => {
</TableCell>
</TableRow>
))}
{permissions.length === 0 && (
{!loading && permissions.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary', py: 4 }}>
No permissions defined
@@ -154,6 +189,7 @@ const PermissionsTab: React.FC = () => {
)}
</TableBody>
</Table>
</TableContainer>
<PermissionFormDialog
open={dialogOpen}

View File

@@ -7,6 +7,7 @@ import {
Button,
TextField,
Stack,
CircularProgress,
} from '@mui/material';
import { RateLimit, CreateRateLimitRequest } from '../../../types';
@@ -25,9 +26,12 @@ const emptyForm: CreateRateLimitRequest = {
max_applications: 5,
};
type FieldErrors = Partial<Record<keyof CreateRateLimitRequest, string>>;
const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLimit, onClose, onSave }) => {
const [form, setForm] = useState<CreateRateLimitRequest>(emptyForm);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
useEffect(() => {
if (rateLimit) {
@@ -41,13 +45,27 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
} else {
setForm(emptyForm);
}
setErrors({});
}, [rateLimit, open]);
const handleChange = (field: keyof CreateRateLimitRequest) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm(prev => ({ ...prev, [field]: Number(e.target.value) }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }));
};
const validate = (): boolean => {
const next: FieldErrors = {};
if (form.burst_duration < 1) next.burst_duration = 'Must be at least 1 second';
if (form.burst_limit < 0) next.burst_limit = 'Must be 0 or greater';
if (form.daily_limit < 0) next.daily_limit = 'Must be 0 or greater';
if (form.monthly_limit < 0) next.monthly_limit = 'Must be 0 or greater';
if (form.max_applications < 0) next.max_applications = 'Must be 0 or greater';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSave = async () => {
if (!validate()) return;
setSaving(true);
try {
await onSave(form);
@@ -57,7 +75,7 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>{rateLimit ? 'Edit Rate Limit' : 'Create Rate Limit'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -67,7 +85,9 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
value={form.burst_duration}
onChange={handleChange('burst_duration')}
fullWidth
inputProps={{ min: 1 }}
error={!!errors.burst_duration}
helperText={errors.burst_duration}
slotProps={{ htmlInput: { min: 1 } }}
/>
<TextField
label="Burst Limit (requests)"
@@ -75,7 +95,9 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
value={form.burst_limit}
onChange={handleChange('burst_limit')}
fullWidth
inputProps={{ min: 0 }}
error={!!errors.burst_limit}
helperText={errors.burst_limit}
slotProps={{ htmlInput: { min: 0 } }}
/>
<TextField
label="Daily Limit (requests)"
@@ -83,7 +105,9 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
value={form.daily_limit}
onChange={handleChange('daily_limit')}
fullWidth
inputProps={{ min: 0 }}
error={!!errors.daily_limit}
helperText={errors.daily_limit}
slotProps={{ htmlInput: { min: 0 } }}
/>
<TextField
label="Monthly Limit (requests)"
@@ -91,7 +115,9 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
value={form.monthly_limit}
onChange={handleChange('monthly_limit')}
fullWidth
inputProps={{ min: 0 }}
error={!!errors.monthly_limit}
helperText={errors.monthly_limit}
slotProps={{ htmlInput: { min: 0 } }}
/>
<TextField
label="Max Applications"
@@ -99,13 +125,20 @@ const RateLimitFormDialog: React.FC<RateLimitFormDialogProps> = ({ open, rateLim
value={form.max_applications}
onChange={handleChange('max_applications')}
fullWidth
inputProps={{ min: 0 }}
error={!!errors.max_applications}
helperText={errors.max_applications}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
<Button
onClick={handleSave}
variant="contained"
disabled={saving}
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>

View File

@@ -7,10 +7,13 @@ import {
TableBody,
TableRow,
TableCell,
TableContainer,
IconButton,
Typography,
Tooltip,
CircularProgress,
Paper,
Skeleton,
LinearProgress,
} from '@mui/material';
import {
Add as AddIcon,
@@ -23,6 +26,8 @@ import { useNotification } from '../../../context/NotificationContext';
import RateLimitFormDialog from './RateLimitFormDialog';
import ConfirmDialog from '../ConfirmDialog';
const SKELETON_ROWS = 3;
const RateLimitsTab: React.FC = () => {
const [rateLimits, setRateLimits] = useState<RateLimit[]>([]);
const [loading, setLoading] = useState(true);
@@ -81,16 +86,23 @@ const RateLimitsTab: React.FC = () => {
setPendingDeleteId(null);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Rate Limit Classes</Typography>
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate}>
<Button startIcon={<AddIcon />} variant="contained" onClick={openCreate} disabled={loading}>
New Rate Limit
</Button>
</Box>
<TableContainer
component={Paper}
variant="outlined"
sx={{ position: 'relative', overflow: 'hidden' }}
>
{loading && rateLimits.length > 0 && (
<LinearProgress sx={{ position: 'absolute', top: 0, left: 0, right: 0 }} />
)}
<Table size="small">
<TableHead>
<TableRow>
@@ -104,7 +116,19 @@ const RateLimitsTab: React.FC = () => {
</TableRow>
</TableHead>
<TableBody>
{rateLimits.map(rl => (
{loading && rateLimits.length === 0
? Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton width={24} /></TableCell>
<TableCell><Skeleton width={40} /></TableCell>
<TableCell><Skeleton width={60} /></TableCell>
<TableCell><Skeleton width={70} /></TableCell>
<TableCell><Skeleton width={80} /></TableCell>
<TableCell><Skeleton width={40} /></TableCell>
<TableCell align="right"><Skeleton width={60} sx={{ ml: 'auto' }} /></TableCell>
</TableRow>
))
: rateLimits.map(rl => (
<TableRow key={rl.id} hover>
<TableCell>{rl.id}</TableCell>
<TableCell>{rl.burst_duration}</TableCell>
@@ -126,7 +150,7 @@ const RateLimitsTab: React.FC = () => {
</TableCell>
</TableRow>
))}
{rateLimits.length === 0 && (
{!loading && rateLimits.length === 0 && (
<TableRow>
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary', py: 4 }}>
No rate limit classes defined
@@ -135,6 +159,7 @@ const RateLimitsTab: React.FC = () => {
)}
</TableBody>
</Table>
</TableContainer>
<RateLimitFormDialog
open={dialogOpen}

View File

@@ -59,7 +59,6 @@ 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) {
@@ -79,6 +78,7 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
const [applications, setApplications] = useState<Application[]>([]);
const [loadingApps, setLoadingApps] = useState(false);
const [saving, setSaving] = useState(false);
const [togglingPermIds, setTogglingPermIds] = useState<Set<number>>(new Set());
const { showNotification } = useNotification();
useEffect(() => {
@@ -89,6 +89,7 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
}
setTab(0);
setApplications([]);
setTogglingPermIds(new Set());
}, [user, open]);
useEffect(() => {
@@ -116,16 +117,18 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
const handleTogglePermission = async (perm: Permission, checked: boolean) => {
if (!user) return;
if (perm.is_default) return; // default perms cannot be toggled
if (perm.is_default) return;
setTogglingPermIds(prev => new Set([...prev, perm.id]));
if (checked) {
const res = await adminService.addUserPermission(user.id, perm.id);
if (res.error) { showNotification(res.error, 'error'); return; }
setUserPerms(prev => new Set([...prev, perm.id]));
if (res.error) { showNotification(res.error, 'error'); }
else { setUserPerms(prev => new Set([...prev, perm.id])); }
} else {
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(perm.id); return s; });
if (res.error) { showNotification(res.error, 'error'); }
else { setUserPerms(prev => { const s = new Set(prev); s.delete(perm.id); return s; }); }
}
setTogglingPermIds(prev => { const s = new Set(prev); s.delete(perm.id); return s; });
};
const burstPct = user
@@ -143,14 +146,13 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
const serviceMap = groupByService(allPermissions);
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
User: {user.username}
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{user.username}
<Chip
label={user.active ? 'Active' : 'Disabled'}
color={user.active ? 'success' : 'error'}
size="small"
sx={{ ml: 1 }}
/>
</DialogTitle>
<DialogContent sx={{ minHeight: 360 }}>
@@ -189,7 +191,9 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
{/* Applications */}
<TabPanel value={tab} index={1}>
{loadingApps ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : (
<Table size="small">
<TableHead>
@@ -228,40 +232,48 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
{/* Rate Limit Usage */}
<TabPanel value={tab} index={2}>
<Stack spacing={3}>
<Box>
{[
{
label: 'Burst',
pct: burstPct,
remaining: user.rate_limit_status.remaining_burst,
total: user.rate_limit.burst_limit,
},
{
label: 'Daily',
pct: dailyPct,
remaining: user.rate_limit_status.remaining_daily,
total: user.rate_limit.daily_limit,
},
{
label: 'Monthly',
pct: monthlyPct,
remaining: user.rate_limit_status.remaining_monthly,
total: user.rate_limit.monthly_limit,
},
].map(({ label, pct, remaining, total }) => (
<Box key={label}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">Burst</Typography>
<Typography variant="body2" color="text.secondary">
{user.rate_limit_status.remaining_burst.toLocaleString()} / {user.rate_limit.burst_limit.toLocaleString()} remaining
<Typography variant="body2" fontWeight={500}>{label}</Typography>
<Typography variant="body2" color={pct > 90 ? 'error.main' : 'text.secondary'}>
{remaining.toLocaleString()} / {total.toLocaleString()} remaining
</Typography>
</Box>
<LinearProgress variant="determinate" value={burstPct} color={burstPct > 90 ? 'error' : 'primary'} />
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">Daily</Typography>
<Typography variant="body2" color="text.secondary">
{user.rate_limit_status.remaining_daily.toLocaleString()} / {user.rate_limit.daily_limit.toLocaleString()} remaining
</Typography>
</Box>
<LinearProgress variant="determinate" value={dailyPct} color={dailyPct > 90 ? 'error' : 'primary'} />
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2">Monthly</Typography>
<Typography variant="body2" color="text.secondary">
{user.rate_limit_status.remaining_monthly.toLocaleString()} / {user.rate_limit.monthly_limit.toLocaleString()} remaining
</Typography>
</Box>
<LinearProgress variant="determinate" value={monthlyPct} color={monthlyPct > 90 ? 'error' : 'primary'} />
<LinearProgress
variant="determinate"
value={pct}
color={pct > 90 ? 'error' : pct > 70 ? 'warning' : 'primary'}
sx={{ height: 6, borderRadius: 1 }}
/>
</Box>
))}
<Typography variant="caption" color="text.secondary">
Max applications: {user.rate_limit.max_applications}
</Typography>
</Stack>
</TabPanel>
{/* Permissions — grouped by service, same layout as ApplicationFormDialog */}
{/* Permissions */}
<TabPanel value={tab} index={3}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Permissions are grouped by service. Default permissions cannot be removed.
@@ -297,7 +309,9 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{perms.map(p => (
{perms.map(p => {
const toggling = togglingPermIds.has(p.id);
return (
<Grid size={{ xs: 12, md: 6 }} key={p.id}>
<Paper
variant="outlined"
@@ -305,6 +319,8 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
p: 1,
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderColor: 'rgba(255, 255, 255, 0.12)',
opacity: toggling ? 0.6 : 1,
transition: 'opacity 0.15s',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
@@ -312,12 +328,18 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
>
<FormControlLabel
control={
toggling ? (
<Box sx={{ width: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 1 }}>
<CircularProgress size={16} />
</Box>
) : (
<Checkbox
checked={userPerms.has(p.id)}
disabled={p.is_default}
onChange={e => handleTogglePermission(p, e.target.checked)}
size="small"
/>
)
}
label={
<Box>
@@ -335,7 +357,8 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
/>
</Paper>
</Grid>
))}
);
})}
</Grid>
</AccordionDetails>
</Accordion>
@@ -347,9 +370,14 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
</TabPanel>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
<Button onClick={onClose} disabled={saving}>Close</Button>
{tab === 0 && (
<Button onClick={handleSaveOverview} variant="contained" disabled={saving}>
<Button
onClick={handleSaveOverview}
variant="contained"
disabled={saving}
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{saving ? 'Saving…' : 'Save Changes'}
</Button>
)}

View File

@@ -6,6 +6,7 @@ import {
TableBody,
TableRow,
TableCell,
TableContainer,
IconButton,
InputAdornment,
Typography,
@@ -15,6 +16,9 @@ import {
CircularProgress,
Stack,
TextField,
Paper,
Skeleton,
LinearProgress,
} from '@mui/material';
import {
NavigateBefore as PrevIcon,
@@ -22,6 +26,7 @@ import {
Edit as EditIcon,
Search as SearchIcon,
Clear as ClearIcon,
PersonOff as PersonOffIcon,
} from '@mui/icons-material';
import { AdminUser, RateLimit, Permission } from '../../../types';
import { adminService } from '../../../services/adminService';
@@ -33,12 +38,13 @@ interface UsersTabProps {
allPermissions: Permission[];
}
const SKELETON_ROWS = 8;
const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState('');
// Stack of cursors for going backwards; each entry is the cursor used to load that page
const [cursorStack, setCursorStack] = useState<string[]>([]);
const [currentCursor, setCurrentCursor] = useState('');
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
@@ -107,13 +113,8 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
};
const openUser = async (u: AdminUser) => {
// Fetch full user detail (includes rate limit status)
const res = await adminService.getUser(u.id);
if (res.data) {
setSelectedUser(res.data);
} else {
setSelectedUser(u);
}
setSelectedUser(res.data ?? u);
setDialogOpen(true);
};
@@ -121,15 +122,16 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
load(currentCursor);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
const isInitialLoad = loading && users.length === 0;
const page = cursorStack.length + 1;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Users</Typography>
{!activeSearch && <Typography variant="body2" color="text.secondary">Page {page}</Typography>}
{!activeSearch && !isInitialLoad && (
<Typography variant="body2" color="text.secondary">Page {page}</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
@@ -157,11 +159,25 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
}}
sx={{ flexGrow: 1 }}
/>
<Button variant="contained" size="small" onClick={handleSearch} disabled={!searchInput.trim()}>
<Button
variant="contained"
size="small"
onClick={handleSearch}
disabled={!searchInput.trim() || loading}
startIcon={loading && activeSearch ? <CircularProgress size={14} color="inherit" /> : undefined}
>
Search
</Button>
</Box>
<TableContainer
component={Paper}
variant="outlined"
sx={{ position: 'relative', overflow: 'hidden' }}
>
{loading && !isInitialLoad && (
<LinearProgress sx={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 1 }} />
)}
<Table size="small">
<TableHead>
<TableRow>
@@ -174,8 +190,19 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
</TableRow>
</TableHead>
<TableBody>
{users.map(u => (
<TableRow key={u.id} hover>
{isInitialLoad
? Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton width={32} /></TableCell>
<TableCell><Skeleton width={120} /></TableCell>
<TableCell><Skeleton width={64} height={24} sx={{ borderRadius: 8 }} /></TableCell>
<TableCell><Skeleton width={40} /></TableCell>
<TableCell><Skeleton width={32} /></TableCell>
<TableCell align="right"><Skeleton width={32} sx={{ ml: 'auto' }} /></TableCell>
</TableRow>
))
: users.map(u => (
<TableRow key={u.id} hover sx={{ opacity: loading ? 0.6 : 1, transition: 'opacity 0.15s' }}>
<TableCell>{u.id}</TableCell>
<TableCell>{u.username}</TableCell>
<TableCell>
@@ -189,29 +216,40 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
<TableCell>{u.permissions?.length ?? 0}</TableCell>
<TableCell align="right">
<Tooltip title="Edit user">
<IconButton size="small" onClick={() => openUser(u)}>
<span>
<IconButton size="small" onClick={() => openUser(u)} disabled={loading}>
<EditIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
{!loading && users.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary', py: 4 }}>
No users found
<TableCell colSpan={6} align="center" sx={{ py: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<PersonOffIcon sx={{ fontSize: 36, opacity: 0.4 }} />
<Typography variant="body2">
{activeSearch ? `No users matching "${activeSearch}"` : 'No users found'}
</Typography>
{activeSearch && (
<Button size="small" onClick={handleClearSearch}>Clear search</Button>
)}
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{!activeSearch && (
{!activeSearch && !isInitialLoad && (
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
<Button
startIcon={<PrevIcon />}
onClick={handlePrev}
disabled={cursorStack.length === 0}
disabled={cursorStack.length === 0 || loading}
size="small"
>
Previous
@@ -219,7 +257,7 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
<Button
endIcon={<NextIcon />}
onClick={handleNext}
disabled={!hasMore}
disabled={!hasMore || loading}
size="small"
>
Next