This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,76 +91,105 @@ const PermissionsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleToggleDefault = async (p: Permission) => {
|
||||
await adminService.setPermissionDefault(p.id, !p.is_default);
|
||||
setPermissions(prev => prev.map(item =>
|
||||
item.id === p.id ? { ...item, is_default: !item.is_default } : item
|
||||
));
|
||||
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>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Service</TableCell>
|
||||
<TableCell>Permission Name</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Default</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{permissions.map(p => (
|
||||
<TableRow key={p.id} hover>
|
||||
<TableCell>{p.id}</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>
|
||||
<Tooltip title={p.is_default ? 'Remove default' : 'Set as default'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.is_default}
|
||||
onChange={() => handleToggleDefault(p)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(p)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => requestDelete(p.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{permissions.length === 0 && (
|
||||
|
||||
<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>
|
||||
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary', py: 4 }}>
|
||||
No permissions defined
|
||||
</TableCell>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Service</TableCell>
|
||||
<TableCell>Permission Name</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Default</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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>
|
||||
<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>
|
||||
{togglingDefaultId === p.id ? (
|
||||
<CircularProgress size={18} sx={{ ml: 0.5 }} />
|
||||
) : (
|
||||
<Tooltip title={p.is_default ? 'Remove default' : 'Set as default'}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={p.is_default}
|
||||
onChange={() => handleToggleDefault(p)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(p)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => requestDelete(p.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && permissions.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary', py: 4 }}>
|
||||
No permissions defined
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<PermissionFormDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,60 +86,80 @@ 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>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Burst Duration (s)</TableCell>
|
||||
<TableCell>Burst Limit</TableCell>
|
||||
<TableCell>Daily Limit</TableCell>
|
||||
<TableCell>Monthly Limit</TableCell>
|
||||
<TableCell>Max Apps</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rateLimits.map(rl => (
|
||||
<TableRow key={rl.id} hover>
|
||||
<TableCell>{rl.id}</TableCell>
|
||||
<TableCell>{rl.burst_duration}</TableCell>
|
||||
<TableCell>{rl.burst_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.daily_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.monthly_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.max_applications}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(rl)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => requestDelete(rl.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rateLimits.length === 0 && (
|
||||
|
||||
<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>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary', py: 4 }}>
|
||||
No rate limit classes defined
|
||||
</TableCell>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Burst Duration (s)</TableCell>
|
||||
<TableCell>Burst Limit</TableCell>
|
||||
<TableCell>Daily Limit</TableCell>
|
||||
<TableCell>Monthly Limit</TableCell>
|
||||
<TableCell>Max Apps</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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>
|
||||
<TableCell>{rl.burst_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.daily_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.monthly_limit.toLocaleString()}</TableCell>
|
||||
<TableCell>{rl.max_applications}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(rl)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => requestDelete(rl.id)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && rateLimits.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ color: 'text.secondary', py: 4 }}>
|
||||
No rate limit classes defined
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<RateLimitFormDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -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 ? (
|
||||
<CircularProgress />
|
||||
<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>
|
||||
<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>
|
||||
{[
|
||||
{
|
||||
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" 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={pct}
|
||||
color={pct > 90 ? 'error' : pct > 70 ? 'warning' : 'primary'}
|
||||
sx={{ height: 6, borderRadius: 1 }}
|
||||
/>
|
||||
</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'} />
|
||||
</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,45 +309,56 @@ const UserDetailDialog: React.FC<UserDetailDialogProps> = ({
|
||||
</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' }} />
|
||||
)}
|
||||
{perms.map(p => {
|
||||
const toggling = togglingPermIds.has(p.id);
|
||||
return (
|
||||
<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)',
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
transition: 'opacity 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{p.description || p.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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,61 +159,97 @@ 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>
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Rate Limit ID</TableCell>
|
||||
<TableCell>Permissions</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map(u => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.id}</TableCell>
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={u.active ? 'Active' : 'Disabled'}
|
||||
color={u.active ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{u.rate_limit?.id ?? '—'}</TableCell>
|
||||
<TableCell>{u.permissions?.length ?? 0}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit user">
|
||||
<IconButton size="small" onClick={() => openUser(u)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<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>
|
||||
<TableCell colSpan={6} align="center" sx={{ color: 'text.secondary', py: 4 }}>
|
||||
No users found
|
||||
</TableCell>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Rate Limit ID</TableCell>
|
||||
<TableCell>Permissions</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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>
|
||||
<Chip
|
||||
label={u.active ? 'Active' : 'Disabled'}
|
||||
color={u.active ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{u.rate_limit?.id ?? '—'}</TableCell>
|
||||
<TableCell>{u.permissions?.length ?? 0}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit user">
|
||||
<span>
|
||||
<IconButton size="small" onClick={() => openUser(u)} disabled={loading}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!loading && users.length === 0 && (
|
||||
<TableRow>
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user