diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fc2cd52..73378bc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -117,6 +117,7 @@ import { } from './api'; import type { ConsoleData, StatItem } from './app-state'; import { AppShell } from './components/layout/AppShell'; +import { canAccessAdminWorkspace } from './auth-permissions'; import { LoginRequiredPanel } from './components/LoginRequiredPanel'; import { OIDCCallbackNotice } from './components/OIDCCallbackNotice'; import { useCatalogOperations } from './hooks/useCatalogOperations'; @@ -483,7 +484,8 @@ export function App() { loadedDataKeysRef.current.delete('walletTransactions'); loadingDataKeysRef.current.delete('walletTransactions'); } - await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force); + const routeKeys = dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)); + await ensureData(nextToken ? ['currentUser', ...routeKeys] : routeKeys, nextToken, force); } async function ensureData(keys: DataKey[], nextToken = token, force = false) { @@ -1410,6 +1412,7 @@ export function App() { return ( { + it.each(['admin', 'manager', 'operator'])('allows the %s role', (role) => { + expect(canAccessAdminWorkspace(userWithRoles([role]))).toBe(true); + }); + + it.each([undefined, [], ['user'], ['creator']])('rejects non-admin roles: %j', (roles) => { + expect(canAccessAdminWorkspace(userWithRoles(roles))).toBe(false); + }); + + it('rejects an unresolved user', () => { + expect(canAccessAdminWorkspace(null)).toBe(false); + }); +}); diff --git a/apps/web/src/auth-permissions.ts b/apps/web/src/auth-permissions.ts new file mode 100644 index 0000000..50cccc7 --- /dev/null +++ b/apps/web/src/auth-permissions.ts @@ -0,0 +1,9 @@ +import type { AuthUser } from '@easyai-ai-gateway/contracts'; + +// Keep this aligned with backend roles that grant PermissionPower for read-only +// admin APIs; manager/admin additionally receive write access. +const adminWorkspaceRoles = new Set(['admin', 'manager', 'operator']); + +export function canAccessAdminWorkspace(user: AuthUser | null | undefined) { + return user?.role?.some((role) => adminWorkspaceRoles.has(role)) ?? false; +} diff --git a/apps/web/src/components/layout/AppShell.test.tsx b/apps/web/src/components/layout/AppShell.test.tsx new file mode 100644 index 0000000..7e287db --- /dev/null +++ b/apps/web/src/components/layout/AppShell.test.tsx @@ -0,0 +1,32 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { AppShell } from './AppShell'; + +function renderShell(canAccessAdmin: boolean) { + const handler = vi.fn(); + return renderToStaticMarkup( + +
content
+
, + ); +} + +describe('AppShell', () => { + it('hides the admin workspace navigation without admin access', () => { + expect(renderShell(false)).not.toContain('管理工作台'); + }); + + it('shows the admin workspace navigation with admin access', () => { + expect(renderShell(true)).toContain('管理工作台'); + }); +}); diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 2b84bcd..f70835b 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -15,6 +15,7 @@ const navItems: Array<{ key: PageKey; label: string; icon: ReactNode }> = [ export function AppShell(props: { activePage: PageKey; + canAccessAdmin: boolean; children: ReactNode; health: HealthResponse | null; isAuthenticated: boolean; @@ -35,7 +36,7 @@ export function AppShell(props: {