feat(quotas): per-user resource limits for VM/LXC deployment
Add per-user quotas (max instances, vCPU, RAM, disk) enforced at deploy time and surfaced to users self-service. Backend: - UserQuota model (one-to-one, NULL = unlimited) + migration #28 - quota_service: usage from vm_instances cache, check_quota raises 429 - enforce in VM (templates.py) and LXC (lxc.py) deploy paths - RBAC quota:view / quota:manage, granted to admin - admin API GET/PUT /admin/api/users/{id}/quota - self-service GET /settings/api/quota - tighten VM list isolation via can_view_all_instances Frontend: - admin quota dialog in UsersPage with usage bars - quota card on Settings page (own limits) - remaining-quota hint in CreateInstanceWizard config step - useUserQuota/useSetUserQuota, useMyQuota hooks; RU/EN locales Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound } from 'lucide-react';
|
||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -14,7 +14,7 @@ import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { useProfile, useMyQuota } from '@/hooks/use-settings';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
@@ -418,6 +418,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
{step === 'config' && kind === 'vm' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||
<QuotaHint addCores={config.cores} addMemoryMb={config.memory} addDiskGb={config.disk} />
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* Basic */}
|
||||
<Card>
|
||||
@@ -566,6 +567,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
{step === 'config' && kind === 'lxc' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||
<QuotaHint addCores={lxcConfig.cores} addMemoryMb={lxcConfig.memory} addDiskGb={lxcConfig.disk} />
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* Basic */}
|
||||
<Card>
|
||||
@@ -788,6 +790,48 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the deploying user's remaining quota at the config step, projecting the
|
||||
* currently-entered resources. Hidden when the user has no quota set.
|
||||
* Note: reflects the current user's quota (the common self-deploy case), not an
|
||||
* admin-selected owner's quota.
|
||||
*/
|
||||
function QuotaHint({ addCores, addMemoryMb, addDiskGb }: { addCores: number; addMemoryMb: number; addDiskGb: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: q } = useMyQuota();
|
||||
|
||||
const hasAnyLimit = q && (
|
||||
q.max_instances !== null || q.max_cores !== null ||
|
||||
q.max_memory_mb !== null || q.max_disk_gb !== null
|
||||
);
|
||||
if (!q || !hasAnyLimit) return null;
|
||||
|
||||
const rows: Array<{ label: string; used: number; add: number; limit: number | null }> = [
|
||||
{ label: t('users.quota.instances'), used: q.used_instances, add: 1, limit: q.max_instances },
|
||||
{ label: t('common.vcpu'), used: q.used_cores, add: addCores, limit: q.max_cores },
|
||||
{ label: t('wizard.memory_mb'), used: q.used_memory_mb, add: addMemoryMb, limit: q.max_memory_mb },
|
||||
{ label: t('wizard.disk_gb'), used: q.used_disk_gb, add: addDiskGb, limit: q.max_disk_gb },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="border-amber-500/40">
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Gauge className="h-4 w-4" />{t('users.quota.manage')}</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2 sm:grid-cols-2">
|
||||
{rows.filter(r => r.limit !== null).map(r => {
|
||||
const projected = r.used + r.add;
|
||||
const over = projected > (r.limit as number);
|
||||
return (
|
||||
<div key={r.label} className="flex items-center justify-between text-xs rounded-md border px-2.5 py-1.5">
|
||||
<span className="text-muted-foreground">{r.label}</span>
|
||||
<span className={over ? 'text-destructive font-medium' : ''}>{projected} / {r.limit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SSHOwnerSelector({
|
||||
users, value, onChange,
|
||||
}: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useProfile, useUpdateProfile, useChangePassword,
|
||||
useMyQuota,
|
||||
usePanelSettings, useUpdatePanelSettings,
|
||||
useSecuritySettings, useUpdateSecuritySettings,
|
||||
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
||||
@@ -111,10 +112,57 @@ function ProfileTab() {
|
||||
}}>{t('settings.change_password')}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<QuotaCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: quota, isLoading } = useMyQuota();
|
||||
|
||||
// Hide the card entirely if the user has no limits set (all unlimited)
|
||||
const hasAnyLimit = quota && (
|
||||
quota.max_instances !== null || quota.max_cores !== null ||
|
||||
quota.max_memory_mb !== null || quota.max_disk_gb !== null
|
||||
);
|
||||
if (isLoading || !quota || !hasAnyLimit) return null;
|
||||
|
||||
const rows: Array<{ label: string; used: number; limit: number | null }> = [
|
||||
{ label: t('users.quota.instances'), used: quota.used_instances, limit: quota.max_instances },
|
||||
{ label: t('users.quota.cores'), used: quota.used_cores, limit: quota.max_cores },
|
||||
{ label: t('users.quota.memory_mb'), used: quota.used_memory_mb, limit: quota.max_memory_mb },
|
||||
{ label: t('users.quota.disk_gb'), used: quota.used_disk_gb, limit: quota.max_disk_gb },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('users.quota.manage')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rows.map(r => {
|
||||
const pct = r.limit && r.limit > 0 ? Math.min(100, (r.used / r.limit) * 100) : 0;
|
||||
const over = r.limit !== null && r.used > r.limit;
|
||||
return (
|
||||
<div key={r.label} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{r.label}</span>
|
||||
<span className={over ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{r.used}{r.limit !== null ? ` / ${r.limit}` : ` / ${t('users.quota.unlimited')}`}
|
||||
</span>
|
||||
</div>
|
||||
{r.limit !== null && (
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full ${over ? 'bg-destructive' : 'bg-primary'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = usePanelSettings();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Search, Plus, Shield, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound,
|
||||
Search, Plus, Shield, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound, Gauge,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions,
|
||||
useSessions, useTerminateSession, useTerminateAllSessions,
|
||||
useUserServerAssignments, useSetUserServers,
|
||||
useUserQuota, useSetUserQuota,
|
||||
useSecuritySettings, useUpdateSecuritySettings,
|
||||
useBlockedIps, useBlockIp, useUnblockIp, useSecurityEvents,
|
||||
type Role, type SecuritySettings,
|
||||
@@ -86,6 +87,7 @@ function UsersTab() {
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
const [resetUser, setResetUser] = useState<User | null>(null);
|
||||
const [serversUser, setServersUser] = useState<User | null>(null);
|
||||
const [quotaUser, setQuotaUser] = useState<User | null>(null);
|
||||
const [sshUser, setSshUser] = useState<User | null>(null);
|
||||
|
||||
const { data: users = [] } = useUsers();
|
||||
@@ -175,6 +177,13 @@ function UsersTab() {
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7"
|
||||
title={t('users.quota.manage')}
|
||||
onClick={() => setQuotaUser(u)}
|
||||
>
|
||||
<Gauge className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7"
|
||||
title={t('ssh_keys.manage_for_user')}
|
||||
@@ -258,6 +267,13 @@ function UsersTab() {
|
||||
onOpenChange={o => !o && setServersUser(null)}
|
||||
/>
|
||||
)}
|
||||
{quotaUser && (
|
||||
<UserQuotaDialog
|
||||
user={quotaUser}
|
||||
open={!!quotaUser}
|
||||
onOpenChange={o => !o && setQuotaUser(null)}
|
||||
/>
|
||||
)}
|
||||
{sshUser && (
|
||||
<UserSSHKeysDialog
|
||||
user={sshUser}
|
||||
@@ -607,6 +623,123 @@ function UserServersForm({
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== User Quota Dialog ====================
|
||||
|
||||
function UserQuotaDialog({
|
||||
user, open, onOpenChange,
|
||||
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useUserQuota(user.id);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('users.quota.manage')}: {user.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading || !data ? (
|
||||
<p className="text-sm text-muted-foreground py-4">{t('common.loading')}</p>
|
||||
) : (
|
||||
<UserQuotaForm user={user} data={data} onClose={() => onOpenChange(false)} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UserQuotaForm({
|
||||
user, data, onClose,
|
||||
}: {
|
||||
user: User;
|
||||
data: NonNullable<ReturnType<typeof useUserQuota>['data']>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setQuota = useSetUserQuota();
|
||||
|
||||
// Empty string = unlimited
|
||||
const toField = (v: number | null) => (v === null || v === undefined ? '' : String(v));
|
||||
const [instances, setInstances] = useState(toField(data.max_instances));
|
||||
const [cores, setCores] = useState(toField(data.max_cores));
|
||||
const [memory, setMemory] = useState(toField(data.max_memory_mb));
|
||||
const [disk, setDisk] = useState(toField(data.max_disk_gb));
|
||||
|
||||
const parse = (v: string): number | null => {
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setQuota.mutate(
|
||||
{
|
||||
userId: user.id,
|
||||
max_instances: parse(instances),
|
||||
max_cores: parse(cores),
|
||||
max_memory_mb: parse(memory),
|
||||
max_disk_gb: parse(disk),
|
||||
},
|
||||
{
|
||||
onSuccess: () => { onClose(); toast.success(t('users.quota.saved')); },
|
||||
onError: e => toast.error(getErrMsg(e)),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const fields: Array<{
|
||||
label: string; value: string; set: (v: string) => void; used: number; limit: number | null;
|
||||
}> = [
|
||||
{ label: t('users.quota.instances'), value: instances, set: setInstances, used: data.used_instances, limit: parse(instances) },
|
||||
{ label: t('users.quota.cores'), value: cores, set: setCores, used: data.used_cores, limit: parse(cores) },
|
||||
{ label: t('users.quota.memory_mb'), value: memory, set: setMemory, used: data.used_memory_mb, limit: parse(memory) },
|
||||
{ label: t('users.quota.disk_gb'), value: disk, set: setDisk, used: data.used_disk_gb, limit: parse(disk) },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">{t('users.quota.hint')}</p>
|
||||
{fields.map(f => {
|
||||
const pct = f.limit && f.limit > 0 ? Math.min(100, (f.used / f.limit) * 100) : 0;
|
||||
const over = f.limit !== null && f.used > f.limit;
|
||||
return (
|
||||
<div key={f.label} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">{f.label}</Label>
|
||||
<span className={`text-xs ${over ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{t('users.quota.used')}: {f.used}
|
||||
{f.limit !== null ? ` / ${f.limit}` : ` / ${t('users.quota.unlimited')}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={f.value}
|
||||
onChange={e => f.set(e.target.value)}
|
||||
placeholder={t('users.quota.unlimited')}
|
||||
className="w-40"
|
||||
/>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${over ? 'bg-destructive' : 'bg-primary'}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
|
||||
<Button onClick={submit} disabled={setQuota.isPending}>{t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSSHKeysDialog({
|
||||
user, open, onOpenChange,
|
||||
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiClient } from '@/lib/api-client';
|
||||
|
||||
export const settingsKeys = {
|
||||
profile: ['settings-profile'] as const,
|
||||
quota: ['settings-quota'] as const,
|
||||
panel: ['settings-panel'] as const,
|
||||
security: ['settings-security'] as const,
|
||||
channels: ['notification-channels'] as const,
|
||||
@@ -59,6 +60,25 @@ export function useProfile() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface MyQuota {
|
||||
user_id: number;
|
||||
max_instances: number | null;
|
||||
max_cores: number | null;
|
||||
max_memory_mb: number | null;
|
||||
max_disk_gb: number | null;
|
||||
used_instances: number;
|
||||
used_cores: number;
|
||||
used_memory_mb: number;
|
||||
used_disk_gb: number;
|
||||
}
|
||||
|
||||
export function useMyQuota() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.quota,
|
||||
queryFn: () => apiClient.get<MyQuota>('/settings/api/quota'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -6,6 +6,7 @@ export const userKeys = {
|
||||
users: ['users'] as const,
|
||||
user: (id: number) => ['user', id] as const,
|
||||
userServers: (id: number) => ['user-server-assignments', id] as const,
|
||||
userQuota: (id: number) => ['user-quota', id] as const,
|
||||
roles: ['roles'] as const,
|
||||
permissions: ['permissions-v2'] as const,
|
||||
sessions: ['sessions'] as const,
|
||||
@@ -208,6 +209,47 @@ export function useSetUserServers() {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== User → Quota ====================
|
||||
|
||||
export interface UserQuota {
|
||||
user_id: number;
|
||||
max_instances: number | null;
|
||||
max_cores: number | null;
|
||||
max_memory_mb: number | null;
|
||||
max_disk_gb: number | null;
|
||||
used_instances: number;
|
||||
used_cores: number;
|
||||
used_memory_mb: number;
|
||||
used_disk_gb: number;
|
||||
}
|
||||
|
||||
export interface SetUserQuotaPayload {
|
||||
max_instances?: number | null;
|
||||
max_cores?: number | null;
|
||||
max_memory_mb?: number | null;
|
||||
max_disk_gb?: number | null;
|
||||
}
|
||||
|
||||
export function useUserQuota(userId: number) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.userQuota(userId),
|
||||
queryFn: () => apiClient.get<UserQuota>(`/admin/api/users/${userId}/quota`),
|
||||
enabled: userId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetUserQuota() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, ...data }: { userId: number } & SetUserQuotaPayload) =>
|
||||
apiClient.put<UserQuota>(`/admin/api/users/${userId}/quota`, data),
|
||||
onSuccess: (_, vars) => {
|
||||
qc.invalidateQueries({ queryKey: userKeys.userQuota(vars.userId) });
|
||||
qc.invalidateQueries({ queryKey: userKeys.users });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Roles ====================
|
||||
|
||||
export function useRoles() {
|
||||
|
||||
@@ -46,6 +46,10 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
path: string;
|
||||
// Permission ("resource:action") the destination page requires. When set, the
|
||||
// item is hidden unless the user is an admin or has that permission granted.
|
||||
// When omitted, the item is admin-only.
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -53,41 +57,65 @@ interface NavGroup {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// Each item is gated by the permission its page's API actually requires, so a
|
||||
// menu only appears when the user can use the page behind it. Admins bypass all
|
||||
// checks. Items without a `permission` are admin-only (no user-facing API).
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: 'Infrastructure',
|
||||
items: [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
|
||||
{ label: 'Instances', icon: Monitor, path: '/instances' },
|
||||
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
||||
{ label: 'Cluster', icon: Network, path: '/cluster' },
|
||||
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
||||
{ label: 'Images', icon: HardDriveDownload, path: '/images' },
|
||||
{ label: 'Backups', icon: Archive, path: '/backups' },
|
||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard', permission: 'dashboard:view' },
|
||||
{ label: 'Instances', icon: Monitor, path: '/instances', permission: 'vm:view' },
|
||||
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
|
||||
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
|
||||
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
|
||||
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
|
||||
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
|
||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
items: [
|
||||
{ label: 'IPAM', icon: Globe, path: '/ipam' },
|
||||
{ label: 'Networks', icon: Network, path: '/networks' },
|
||||
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
|
||||
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Management',
|
||||
items: [
|
||||
{ label: 'Users', icon: Users, path: '/users' },
|
||||
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' },
|
||||
{ label: 'Logs', icon: FileText, path: '/logs' },
|
||||
{ label: 'Settings', icon: Settings, path: '/settings' },
|
||||
{ label: 'Users', icon: Users, path: '/users', permission: 'user:view' },
|
||||
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' }, // admin-only
|
||||
{ label: 'Logs', icon: FileText, path: '/logs', permission: 'log:view' },
|
||||
{ label: 'Settings', icon: Settings, path: '/settings', permission: 'setting:view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Whether the current user may see a given nav item. Admins see everything. */
|
||||
function canSeeNavItem(
|
||||
item: NavItem,
|
||||
isAdmin: boolean,
|
||||
permissions: Record<string, boolean> | undefined,
|
||||
): boolean {
|
||||
if (isAdmin) return true;
|
||||
if (!item.permission) return false; // admin-only item
|
||||
return !!permissions?.[item.permission];
|
||||
}
|
||||
|
||||
function SidebarContent() {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const isAdmin = !!user?.is_admin;
|
||||
const permissions = user?.permissions;
|
||||
|
||||
const visibleGroups = navGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => canSeeNavItem(item, isAdmin, permissions)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -100,7 +128,7 @@ function SidebarContent() {
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
|
||||
{navGroups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`nav.${group.title.toLowerCase()}`, group.title)}
|
||||
|
||||
@@ -849,7 +849,18 @@
|
||||
"event_type": "Event Type",
|
||||
"workspaces": "Workspaces",
|
||||
"no_workspaces": "No workspaces",
|
||||
"workspace_required": "User must belong to the server's workspace"
|
||||
"workspace_required": "User must belong to the server's workspace",
|
||||
"quota": {
|
||||
"manage": "Quotas (limits)",
|
||||
"hint": "Leave a field empty for no limit on that metric.",
|
||||
"instances": "Max instances (VM + LXC)",
|
||||
"cores": "Max vCPU (cores)",
|
||||
"memory_mb": "Max RAM (MB)",
|
||||
"disk_gb": "Max disk (GB)",
|
||||
"used": "Used",
|
||||
"unlimited": "unlimited",
|
||||
"saved": "Quota saved"
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"create": "Create Workspace",
|
||||
|
||||
@@ -849,7 +849,18 @@
|
||||
"event_type": "Тип события",
|
||||
"workspaces": "Рабочие области",
|
||||
"no_workspaces": "Нет рабочих областей",
|
||||
"workspace_required": "Пользователь должен принадлежать рабочей области сервера"
|
||||
"workspace_required": "Пользователь должен принадлежать рабочей области сервера",
|
||||
"quota": {
|
||||
"manage": "Квоты (лимиты)",
|
||||
"hint": "Пустое поле — без ограничения по этой метрике.",
|
||||
"instances": "Макс. инстансов (VM + LXC)",
|
||||
"cores": "Макс. vCPU (ядер)",
|
||||
"memory_mb": "Макс. RAM (МБ)",
|
||||
"disk_gb": "Макс. диск (ГБ)",
|
||||
"used": "Использовано",
|
||||
"unlimited": "без лимита",
|
||||
"saved": "Квоты сохранены"
|
||||
}
|
||||
},
|
||||
"workspaces": {
|
||||
"create": "Создать рабочую область",
|
||||
|
||||
@@ -15,7 +15,8 @@ export interface User {
|
||||
failed_login_attempts?: number;
|
||||
locked_until?: string | null;
|
||||
require_password_change?: boolean;
|
||||
permissions?: string[];
|
||||
// Map of "resource:action" -> granted. Admins receive every permission.
|
||||
permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
|
||||
Reference in New Issue
Block a user