fix(rbac): guard routes by permission and hide admin-only tabs
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
Hiding a sidebar entry was the only frontend gate: /users, /logs and /settings opened for anyone who typed the URL, and their pages rendered every tab regardless of the caller (the backend then answered the tab's requests with 403s). Route elements are now wrapped in RequirePermission, gated on the same permission the page API requires, so a hidden menu item can no longer be reached by URL. Denied users land on the first page they can open, or on an explanatory screen when nothing is reachable, so the redirect cannot loop. Permission checking moves into lib/permissions.ts and canSeeNavItem delegates to it, keeping menu and guard in sync. Settings hides the panel tab without setting:update, the security tab without setting:manage, the SMTP/Telegram cards from non-admins, and the update controls behind setting:manage; profile, SSH keys and personal notification preferences stay available to everyone. Users hides the roles tab without role:view and the security tab without setting:manage.
This commit is contained in:
@@ -1,15 +1,72 @@
|
|||||||
import { Navigate, Outlet } from 'react-router';
|
import { useEffect } from 'react';
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { hasPermission } from '@/lib/permissions';
|
||||||
|
import { firstAccessiblePath } from '@/lib/nav-items';
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks a route unless the user holds `permission` (admins bypass). Omitting
|
||||||
|
* `permission` makes the route admin-only, matching the nav-item convention.
|
||||||
|
*
|
||||||
|
* Denied users are sent to the first page they can actually open; when nothing
|
||||||
|
* is reachable (or that page is the one being denied) an explanatory screen is
|
||||||
|
* rendered instead, so the redirect can never loop. The backend enforces the
|
||||||
|
* same permission on every endpoint — this guard only keeps users out of pages
|
||||||
|
* that would answer them with 403s.
|
||||||
|
*/
|
||||||
|
export function RequirePermission({
|
||||||
|
permission,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
permission?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Authenticated but /me has not resolved yet: permissions are unknown, so
|
||||||
|
// wait instead of bouncing the user off a page they are allowed to see.
|
||||||
|
const pending = isAuthenticated && !user;
|
||||||
|
const allowed = hasPermission(!!user?.is_admin, user?.permissions, permission);
|
||||||
|
const fallback = firstAccessiblePath(!!user?.is_admin, user?.permissions);
|
||||||
|
const redirectTo = fallback && fallback !== location.pathname ? fallback : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending || allowed) return;
|
||||||
|
toast.error(t('common.access_denied'), { id: 'access-denied' });
|
||||||
|
}, [pending, allowed, location.pathname, t]);
|
||||||
|
|
||||||
|
if (pending) return <Spinner />;
|
||||||
|
if (allowed) return <>{children}</>;
|
||||||
|
if (redirectTo) return <Navigate to={redirectTo} replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
|
<ShieldAlert className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">{t('common.access_denied')}</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">{t('common.access_denied_desc')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <Spinner />;
|
||||||
<div className="flex h-dvh items-center justify-center">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|||||||
@@ -28,9 +28,15 @@ import { SSHKeysManager } from './SSHKeysManager';
|
|||||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useHasPermission } from '@/lib/permissions';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Profile, SSH keys and personal notification preferences belong to every
|
||||||
|
// user; the panel-wide tabs are gated on the permission their API requires.
|
||||||
|
const canEditPanel = useHasPermission('setting:update');
|
||||||
|
const canManageSecurity = useHasPermission('setting:manage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">{t('nav.settings')}</h1>
|
<h1 className="text-2xl font-bold">{t('nav.settings')}</h1>
|
||||||
@@ -38,8 +44,8 @@ export default function SettingsPage() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
|
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
|
||||||
<TabsTrigger value="ssh-keys">{t('ssh_keys.title')}</TabsTrigger>
|
<TabsTrigger value="ssh-keys">{t('ssh_keys.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
|
{canEditPanel && <TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>}
|
||||||
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
|
{canManageSecurity && <TabsTrigger value="security">{t('settings.security')}</TabsTrigger>}
|
||||||
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
|
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
|
||||||
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
|
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -50,8 +56,8 @@ export default function SettingsPage() {
|
|||||||
<CardContent><SSHKeysManager /></CardContent>
|
<CardContent><SSHKeysManager /></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="panel"><PanelTab /></TabsContent>
|
{canEditPanel && <TabsContent value="panel"><PanelTab /></TabsContent>}
|
||||||
<TabsContent value="security"><SecurityTab /></TabsContent>
|
{canManageSecurity && <TabsContent value="security"><SecurityTab /></TabsContent>}
|
||||||
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
|
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
|
||||||
<TabsContent value="about"><AboutTab /></TabsContent>
|
<TabsContent value="about"><AboutTab /></TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -384,6 +390,18 @@ function SecurityTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NotificationsTab() {
|
function NotificationsTab() {
|
||||||
|
// The SMTP/Telegram channels are panel-wide and admin-only on the backend;
|
||||||
|
// the preferences card below is every user's own notification settings.
|
||||||
|
const isAdmin = useHasPermission();
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isAdmin && <NotificationChannelsCards />}
|
||||||
|
<NotificationPreferencesCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationChannelsCards() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: channels } = useNotificationChannels();
|
const { data: channels } = useNotificationChannels();
|
||||||
const updateSmtp = useUpdateSMTP();
|
const updateSmtp = useUpdateSMTP();
|
||||||
@@ -461,8 +479,6 @@ function NotificationsTab() {
|
|||||||
})}>{t('common.save')}</Button>
|
})}>{t('common.save')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<NotificationPreferencesCard />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -578,8 +594,28 @@ function NotificationPreferencesCard() {
|
|||||||
|
|
||||||
function AboutTab() {
|
function AboutTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
|
||||||
const { data: version } = useAppVersion();
|
const { data: version } = useAppVersion();
|
||||||
|
// Everyone may see which version they are on; the update machinery behind it
|
||||||
|
// is admin territory (`setting:manage` on the backend).
|
||||||
|
const canUpdate = useHasPermission('setting:manage');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
|
||||||
|
<Badge variant="outline">{version?.version || '—'}</Badge>
|
||||||
|
</div>
|
||||||
|
{canUpdate && <UpdateSection />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdateSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
const checkUpdates = useCheckUpdates();
|
const checkUpdates = useCheckUpdates();
|
||||||
const { data: repo } = useUpdateRepository();
|
const { data: repo } = useUpdateRepository();
|
||||||
const setRepo = useSetUpdateRepository();
|
const setRepo = useSetUpdateRepository();
|
||||||
@@ -614,84 +650,76 @@ function AboutTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
|
{/* Repository */}
|
||||||
<CardContent className="space-y-4">
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('settings.update_repository')}</Label>
|
||||||
|
{repoEdit ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
|
<Input value={repoUrl} onChange={e => setRepoUrl(e.target.value)} placeholder="https://git.example.com/owner/repo" />
|
||||||
<Badge variant="outline">{version?.version || '—'}</Badge>
|
<Button size="sm" onClick={handleSaveRepo} disabled={setRepo.isPending || !repoUrl}>{t('common.save')}</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setRepoEdit(false); setRepoUrl(repo?.repository_url || ''); }}>{t('common.cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{/* Repository */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="space-y-1">
|
<code className="rounded bg-muted px-2 py-1 text-xs">{repo?.repository_url || '—'}</code>
|
||||||
<Label className="text-xs text-muted-foreground">{t('settings.update_repository')}</Label>
|
<Button size="sm" variant="ghost" onClick={() => setRepoEdit(true)}>{t('common.edit', 'Edit')}</Button>
|
||||||
{repoEdit ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input value={repoUrl} onChange={e => setRepoUrl(e.target.value)} placeholder="https://git.example.com/owner/repo" />
|
|
||||||
<Button size="sm" onClick={handleSaveRepo} disabled={setRepo.isPending || !repoUrl}>{t('common.save')}</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => { setRepoEdit(false); setRepoUrl(repo?.repository_url || ''); }}>{t('common.cancel')}</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="rounded bg-muted px-2 py-1 text-xs">{repo?.repository_url || '—'}</code>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setRepoEdit(true)}>{t('common.edit', 'Edit')}</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Check */}
|
{/* Check */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()} disabled={checkUpdates.isPending}>
|
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()} disabled={checkUpdates.isPending}>
|
||||||
{t('settings.check_updates')}
|
{t('settings.check_updates')}
|
||||||
</Button>
|
</Button>
|
||||||
{updating && (
|
{updating && (
|
||||||
<Button size="sm" onClick={handlePerform} disabled={performUpdate.isPending || status?.is_updating}>
|
<Button size="sm" onClick={handlePerform} disabled={performUpdate.isPending || status?.is_updating}>
|
||||||
{t('settings.perform_update')}
|
{t('settings.perform_update')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{check && (
|
{check && (
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
{check.error ? (
|
{check.error ? (
|
||||||
<p className="text-destructive">{check.error}</p>
|
<p className="text-destructive">{check.error}</p>
|
||||||
) : check.update_available ? (
|
) : check.update_available ? (
|
||||||
<p className="font-medium text-warning">
|
<p className="font-medium text-warning">
|
||||||
{t('settings.update_available')}: {check.latest_version}
|
{t('settings.update_available')}: {check.latest_version}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">{t('settings.up_to_date')}</p>
|
<p className="text-muted-foreground">{t('settings.up_to_date')}</p>
|
||||||
)}
|
|
||||||
{check.changelog && (
|
|
||||||
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{check.changelog}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{check.changelog && (
|
||||||
{/* Update in progress / failed banner */}
|
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{check.changelog}</pre>
|
||||||
{status && (status.is_updating || status.stage === 'failed' || status.error) && (
|
|
||||||
<div className={`rounded-md border p-3 text-sm ${
|
|
||||||
status.stage === 'failed' || status.error
|
|
||||||
? 'border-destructive/40 bg-destructive/10'
|
|
||||||
: 'border-warning/40 bg-warning/10'
|
|
||||||
}`}>
|
|
||||||
{status.stage === 'failed' || status.error ? (
|
|
||||||
<>
|
|
||||||
<p className="font-medium text-destructive">{t('settings.update_failed')}</p>
|
|
||||||
{status.error && <p className="mt-1 text-xs text-muted-foreground">{status.error}</p>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="font-medium">{t('settings.update_in_progress')}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" className="mt-2" onClick={() => resetUpdate.mutate()} disabled={resetUpdate.isPending}>
|
|
||||||
{t('settings.update_reset')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
{/* Update in progress / failed banner */}
|
||||||
|
{status && (status.is_updating || status.stage === 'failed' || status.error) && (
|
||||||
|
<div className={`rounded-md border p-3 text-sm ${
|
||||||
|
status.stage === 'failed' || status.error
|
||||||
|
? 'border-destructive/40 bg-destructive/10'
|
||||||
|
: 'border-warning/40 bg-warning/10'
|
||||||
|
}`}>
|
||||||
|
{status.stage === 'failed' || status.error ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-destructive">{t('settings.update_failed')}</p>
|
||||||
|
{status.error && <p className="mt-1 text-xs text-muted-foreground">{status.error}</p>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="font-medium">{t('settings.update_in_progress')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="mt-2" onClick={() => resetUpdate.mutate()} disabled={resetUpdate.isPending}>
|
||||||
|
{t('settings.update_reset')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from '@/hooks/use-users';
|
} from '@/hooks/use-users';
|
||||||
import { SSHKeysManager } from '@/features/settings/SSHKeysManager';
|
import { SSHKeysManager } from '@/features/settings/SSHKeysManager';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useHasPermission } from '@/lib/permissions';
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
@@ -63,20 +64,26 @@ function getErrMsg(e: unknown): string {
|
|||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Each tab is gated on the permission its own API requires: the user list and
|
||||||
|
// active sessions need `user:view` (already required to open this page), while
|
||||||
|
// roles and the security settings are separate permissions.
|
||||||
|
const canViewRoles = useHasPermission('role:view');
|
||||||
|
const canManageSecurity = useHasPermission('setting:manage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">{t('nav.users')}</h1>
|
<h1 className="text-2xl font-bold">{t('nav.users')}</h1>
|
||||||
<Tabs defaultValue="users">
|
<Tabs defaultValue="users">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="users">{t('users.users')}</TabsTrigger>
|
<TabsTrigger value="users">{t('users.users')}</TabsTrigger>
|
||||||
<TabsTrigger value="roles">{t('users.roles')}</TabsTrigger>
|
{canViewRoles && <TabsTrigger value="roles">{t('users.roles')}</TabsTrigger>}
|
||||||
<TabsTrigger value="sessions">{t('users.sessions')}</TabsTrigger>
|
<TabsTrigger value="sessions">{t('users.sessions')}</TabsTrigger>
|
||||||
<TabsTrigger value="security">{t('users.security')}</TabsTrigger>
|
{canManageSecurity && <TabsTrigger value="security">{t('users.security')}</TabsTrigger>}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="users"><UsersTab /></TabsContent>
|
<TabsContent value="users"><UsersTab /></TabsContent>
|
||||||
<TabsContent value="roles"><RolesTab /></TabsContent>
|
{canViewRoles && <TabsContent value="roles"><RolesTab /></TabsContent>}
|
||||||
<TabsContent value="sessions"><SessionsTab /></TabsContent>
|
<TabsContent value="sessions"><SessionsTab /></TabsContent>
|
||||||
<TabsContent value="security"><SecurityTab /></TabsContent>
|
{canManageSecurity && <TabsContent value="security"><SecurityTab /></TabsContent>}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { hasPermission } from './permissions';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -83,7 +84,22 @@ export function canSeeNavItem(
|
|||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
permissions: Record<string, boolean> | undefined,
|
permissions: Record<string, boolean> | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (isAdmin) return true;
|
return hasPermission(isAdmin, permissions, item.permission);
|
||||||
if (!item.permission) return false; // admin-only item
|
}
|
||||||
return !!permissions?.[item.permission];
|
|
||||||
|
/**
|
||||||
|
* Path of the first nav item the user may open, or null when nothing is
|
||||||
|
* reachable. Used as the landing page for users who lack `dashboard:view` and
|
||||||
|
* as the redirect target when a route guard denies access.
|
||||||
|
*/
|
||||||
|
export function firstAccessiblePath(
|
||||||
|
isAdmin: boolean,
|
||||||
|
permissions: Record<string, boolean> | undefined,
|
||||||
|
): string | null {
|
||||||
|
for (const group of navGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (canSeeNavItem(item, isAdmin, permissions)) return item.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
27
frontend/src/lib/permissions.ts
Normal file
27
frontend/src/lib/permissions.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a user holding `permissions` may use `permission`.
|
||||||
|
*
|
||||||
|
* Mirrors the backend `PermissionEngine.has_permission`: admins bypass every
|
||||||
|
* check. A missing `permission` means "admin only" — the same convention the
|
||||||
|
* nav items use for entries without a user-facing API.
|
||||||
|
*/
|
||||||
|
export function hasPermission(
|
||||||
|
isAdmin: boolean,
|
||||||
|
permissions: Record<string, boolean> | undefined,
|
||||||
|
permission?: string,
|
||||||
|
): boolean {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (!permission) return false; // admin-only
|
||||||
|
return !!permissions?.[permission];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive `hasPermission` for the logged-in user. Use it to hide admin-only
|
||||||
|
* UI (tabs, cards, actions); the backend still enforces the same permission,
|
||||||
|
* so this is UX, not a security boundary.
|
||||||
|
*/
|
||||||
|
export function useHasPermission(permission?: string): boolean {
|
||||||
|
return useAuthStore((s) => hasPermission(!!s.user?.is_admin, s.user?.permissions, permission));
|
||||||
|
}
|
||||||
@@ -264,6 +264,8 @@
|
|||||||
"recent_activity": "Recent Activity"
|
"recent_activity": "Recent Activity"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"access_denied": "Access denied",
|
||||||
|
"access_denied_desc": "You do not have permission to open this page. Contact an administrator if you need access.",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
|
|||||||
@@ -264,6 +264,8 @@
|
|||||||
"recent_activity": "Последняя активность"
|
"recent_activity": "Последняя активность"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"access_denied": "Доступ запрещён",
|
||||||
|
"access_denied_desc": "У вас нет прав на просмотр этой страницы. Обратитесь к администратору, если доступ необходим.",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"saved": "Сохранено",
|
"saved": "Сохранено",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
|
|||||||
import AppLayout from '@/layouts/AppLayout';
|
import AppLayout from '@/layouts/AppLayout';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/AuthLayout';
|
||||||
import ConsoleLayout from '@/layouts/ConsoleLayout';
|
import ConsoleLayout from '@/layouts/ConsoleLayout';
|
||||||
import { ProtectedRoute, PublicRoute } from '@/components/shared/route-guards';
|
import { ProtectedRoute, PublicRoute, RequirePermission } from '@/components/shared/route-guards';
|
||||||
import LoginPage from '@/features/auth/LoginPage';
|
import LoginPage from '@/features/auth/LoginPage';
|
||||||
const ForgotPasswordPage = lazy(() => import('./features/auth/ForgotPasswordPage.tsx'));
|
const ForgotPasswordPage = lazy(() => import('./features/auth/ForgotPasswordPage.tsx'));
|
||||||
const ResetPasswordPage = lazy(() => import('./features/auth/ResetPasswordPage.tsx'));
|
const ResetPasswordPage = lazy(() => import('./features/auth/ResetPasswordPage.tsx'));
|
||||||
@@ -48,6 +48,19 @@ function SuspenseWrap({ children }: { children: React.ReactNode }) {
|
|||||||
return <Suspense fallback={<LazyFallback />}>{children}</Suspense>;
|
return <Suspense fallback={<LazyFallback />}>{children}</Suspense>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guarded page. `perm` is the permission the page's own API requires — the
|
||||||
|
* same code the matching nav item is gated on, so a hidden menu entry can no
|
||||||
|
* longer be reached by typing its URL. Omit `perm` for admin-only pages.
|
||||||
|
*/
|
||||||
|
function Page({ perm, children }: { perm?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission={perm}>
|
||||||
|
<SuspenseWrap>{children}</SuspenseWrap>
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
element: <PublicRoute />,
|
element: <PublicRoute />,
|
||||||
@@ -69,45 +82,45 @@ export const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: '/console/node/:serverId/:node',
|
path: '/console/node/:serverId/:node',
|
||||||
element: <ConsoleLayout />,
|
element: <ConsoleLayout />,
|
||||||
children: [{ index: true, element: <SuspenseWrap><NodeShellPage /></SuspenseWrap> }],
|
children: [{ index: true, element: <Page perm="node:upgrade"><NodeShellPage /></Page> }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/console/:serverId/:vmid',
|
path: '/console/:serverId/:vmid',
|
||||||
element: <ConsoleLayout />,
|
element: <ConsoleLayout />,
|
||||||
children: [{ index: true, element: <ConsolePage /> }],
|
children: [{ index: true, element: <Page perm="vm:console"><ConsolePage /></Page> }],
|
||||||
},
|
},
|
||||||
// Main app layout
|
// Main app layout
|
||||||
{
|
{
|
||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <Page perm="dashboard:view"><DashboardPage /></Page> },
|
||||||
{ path: '/instances', element: <InstancesPage /> },
|
{ path: '/instances', element: <Page perm="vm:view"><InstancesPage /></Page> },
|
||||||
{ path: '/instances/create', element: <SuspenseWrap><CreateInstanceWizard /></SuspenseWrap> },
|
{ path: '/instances/create', element: <Page perm="vm:create"><CreateInstanceWizard /></Page> },
|
||||||
{ path: '/instances/snapshot-archives', element: <SuspenseWrap><SnapshotArchivesPage /></SuspenseWrap> },
|
{ path: '/instances/snapshot-archives', element: <Page perm="log:view"><SnapshotArchivesPage /></Page> },
|
||||||
{ path: '/instances/:serverId/:vmid', element: <InstanceDetailPage /> },
|
{ path: '/instances/:serverId/:vmid', element: <Page perm="vm:view"><InstanceDetailPage /></Page> },
|
||||||
{ path: '/nodes', element: <SuspenseWrap><NodesPage /></SuspenseWrap> },
|
{ path: '/nodes', element: <Page perm="server:view"><NodesPage /></Page> },
|
||||||
{ path: '/nodes/:serverId', element: <SuspenseWrap><NodeDetailPage /></SuspenseWrap> },
|
{ path: '/nodes/:serverId', element: <Page perm="server:view"><NodeDetailPage /></Page> },
|
||||||
{ path: '/cluster', element: <SuspenseWrap><ClusterPage /></SuspenseWrap> },
|
{ path: '/cluster', element: <Page perm="cluster:manage"><ClusterPage /></Page> },
|
||||||
{ path: '/templates', element: <SuspenseWrap><TemplatesPage /></SuspenseWrap> },
|
{ path: '/templates', element: <Page perm="template:view"><TemplatesPage /></Page> },
|
||||||
{ path: '/appstore', element: <SuspenseWrap><AppStorePage /></SuspenseWrap> },
|
{ path: '/appstore', element: <Page perm="app:view"><AppStorePage /></Page> },
|
||||||
{ path: '/appstore/:appId', element: <SuspenseWrap><AppDetailPage /></SuspenseWrap> },
|
{ path: '/appstore/:appId', element: <Page perm="app:view"><AppDetailPage /></Page> },
|
||||||
{ path: '/my-apps', element: <SuspenseWrap><MyAppsPage /></SuspenseWrap> },
|
{ path: '/my-apps', element: <Page perm="app:view"><MyAppsPage /></Page> },
|
||||||
{ path: '/scripts', element: <SuspenseWrap><ScriptsPage /></SuspenseWrap> },
|
{ path: '/scripts', element: <Page perm="script:view"><ScriptsPage /></Page> },
|
||||||
{ path: '/images', element: <SuspenseWrap><ImagesPage /></SuspenseWrap> },
|
{ path: '/images', element: <Page perm="template:manage"><ImagesPage /></Page> },
|
||||||
{ path: '/backups', element: <SuspenseWrap><BackupsPage /></SuspenseWrap> },
|
{ path: '/backups', element: <Page perm="backup:view"><BackupsPage /></Page> },
|
||||||
{ path: '/tasks', element: <SuspenseWrap><TasksPage /></SuspenseWrap> },
|
{ path: '/tasks', element: <Page perm="vm:view"><TasksPage /></Page> },
|
||||||
{ path: '/ipam', element: <SuspenseWrap><IPAMDashboardPage /></SuspenseWrap> },
|
{ path: '/ipam', element: <Page perm="ipam:view"><IPAMDashboardPage /></Page> },
|
||||||
{ path: '/ipam/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
|
{ path: '/ipam/networks', element: <Page perm="ipam:view"><IPAMNetworksPage /></Page> },
|
||||||
{ path: '/ipam/network/:id', element: <SuspenseWrap><IPAMNetworkDetailPage /></SuspenseWrap> },
|
{ path: '/ipam/network/:id', element: <Page perm="ipam:view"><IPAMNetworkDetailPage /></Page> },
|
||||||
{ path: '/ipam/allocations', element: <SuspenseWrap><IPAMAllocationsPage /></SuspenseWrap> },
|
{ path: '/ipam/allocations', element: <Page perm="ipam:view"><IPAMAllocationsPage /></Page> },
|
||||||
{ path: '/ipam/history', element: <SuspenseWrap><IPAMHistoryPage /></SuspenseWrap> },
|
{ path: '/ipam/history', element: <Page perm="ipam:view"><IPAMHistoryPage /></Page> },
|
||||||
{ path: '/ipam/tools', element: <SuspenseWrap><IPAMToolsPage /></SuspenseWrap> },
|
{ path: '/ipam/tools', element: <Page perm="ipam:view"><IPAMToolsPage /></Page> },
|
||||||
{ path: '/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
|
{ path: '/networks', element: <Page perm="server:manage"><IPAMNetworksPage /></Page> },
|
||||||
{ path: '/users', element: <SuspenseWrap><UsersPage /></SuspenseWrap> },
|
{ path: '/users', element: <Page perm="user:view"><UsersPage /></Page> },
|
||||||
{ path: '/workspaces', element: <SuspenseWrap><WorkspacesPage /></SuspenseWrap> },
|
{ path: '/workspaces', element: <Page><WorkspacesPage /></Page> },
|
||||||
{ path: '/logs', element: <SuspenseWrap><LogsPage /></SuspenseWrap> },
|
{ path: '/logs', element: <Page perm="log:view"><LogsPage /></Page> },
|
||||||
{ path: '/settings', element: <SuspenseWrap><SettingsPage /></SuspenseWrap> },
|
{ path: '/settings', element: <Page perm="setting:view"><SettingsPage /></Page> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user