diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 221943e1..2f72ae3d 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,6 +7,8 @@ import { Analytics } from './pages/Analytics'; import { Settings } from './pages/Settings'; import { Admin } from './pages/Admin'; import { Backend } from './pages/Backend'; +import { RequireRole } from './components/RequireRole'; +import { canAccessAdmin, canAccessBackend } from './lib/roleAccess'; import { Signin } from './pages/Signin'; import { ForgotPassword } from './pages/ForgotPassword'; import { ResetPassword } from './pages/ResetPassword'; @@ -107,8 +109,8 @@ function AuthenticatedApp() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index 8b4a55fc..7858bf1d 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Brain, Bot, BarChart3, Settings, Server, Globe, Shield, Users, Terminal import { UserSelector } from './UserSelector'; import { GraphSelector } from './GraphSelector'; import { useAuth } from '../contexts/AuthContext'; +import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess'; import { McpHealthIndicator } from './McpHealthIndicator'; import FloatingConsole from './FloatingConsole'; import { InsecureConnectionBanner } from './TlsStatusIndicator'; @@ -30,8 +31,8 @@ export function Layout({ children }: LayoutProps) { { name: 'AI & Agents', href: '/agents', icon: Bot, description: 'AI collaboration', comingSoon: true }, { name: 'Analytics', href: '/analytics', icon: BarChart3, description: 'Priority insights', comingSoon: true }, { name: 'Settings', href: '/settings', icon: Settings, description: 'User preferences' }, - { name: 'Admin', href: '/admin', icon: Shield, description: 'System administration', restricted: currentUser?.role !== 'ADMIN' }, - { name: 'System', href: '/backend', icon: Server, description: 'Backend status', restricted: currentUser?.role === 'VIEWER' || currentUser?.role === 'GUEST' }, + { name: 'Admin', href: '/admin', icon: Shield, description: 'System administration', hidden: !canAccessAdmin(currentUser?.role) }, + { name: 'System', href: '/backend', icon: Server, description: 'Backend status', hidden: !canAccessBackend(currentUser?.role) }, ]; return ( @@ -87,7 +88,10 @@ export function Layout({ children }: LayoutProps) { {navigation.map((item) => { const Icon = item.icon; const isActive = location.pathname === item.href; - const isRestricted = item.restricted || (item as { comingSoon?: boolean }).comingSoon; + // Role-gated items the user can't reach are hidden entirely (not + // greyed) — no point advertising a page they can never open. + if ((item as { hidden?: boolean }).hidden) return null; + const isRestricted = (item as { comingSoon?: boolean }).comingSoon; if (isRestricted) { const restrictionMessage = (item as { comingSoon?: boolean }).comingSoon diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx index d9b2646e..814a0607 100644 --- a/packages/web/src/components/MobileBottomNav.tsx +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react'; import { useViewMode, ViewMode } from '../contexts/ViewModeContext'; import { useAuth } from '../contexts/AuthContext'; +import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess'; /** * The app-wide mobile footer. Present on every page so navigation is consistent: @@ -43,8 +44,8 @@ export function MobileBottomNav() { { href: '/agents', label: 'AI & Agents', Icon: Bot, show: true }, { href: '/analytics', label: 'Analytics', Icon: BarChart3, show: true }, { href: '/settings', label: 'Settings', Icon: SettingsIcon, show: true }, - { href: '/admin', label: 'Admin', Icon: Shield, show: role === 'ADMIN' }, - { href: '/backend', label: 'System', Icon: Server, show: role !== 'VIEWER' && role !== 'GUEST' }, + { href: '/admin', label: 'Admin', Icon: Shield, show: canAccessAdmin(role) }, + { href: '/backend', label: 'System', Icon: Server, show: canAccessBackend(role) }, ].filter((p) => p.show); const tabActive = (active: boolean) => diff --git a/packages/web/src/components/RequireRole.test.tsx b/packages/web/src/components/RequireRole.test.tsx new file mode 100644 index 00000000..56d2f917 --- /dev/null +++ b/packages/web/src/components/RequireRole.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { RequireRole } from './RequireRole'; +import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess'; + +let mockRole: string | undefined | null; +vi.mock('../contexts/AuthContext', () => ({ + useAuth: () => ({ currentUser: mockRole === undefined ? null : { role: mockRole } }), +})); + +function renderGuarded(role: string | undefined | null, can: (r: any) => boolean) { + mockRole = role; + return render( + + + HOME} /> +
PROTECTED PAGE
} /> +
+
, + ); +} + +describe('RequireRole (router guard)', () => { + it('renders the protected page for an allowed role', () => { + renderGuarded('ADMIN', canAccessAdmin); + expect(screen.getByText('PROTECTED PAGE')).toBeTruthy(); + }); + + it('redirects disallowed roles (GUEST/VIEWER/USER) away from /admin', () => { + for (const r of ['GUEST', 'VIEWER', 'USER']) { + const { unmount } = renderGuarded(r, canAccessAdmin); + expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); + expect(screen.getByText('HOME')).toBeTruthy(); + unmount(); + } + }); + + it('redirects when unauthenticated (no user)', () => { + renderGuarded(undefined, canAccessAdmin); + expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); + expect(screen.getByText('HOME')).toBeTruthy(); + }); + + it('backend guard: blocks GUEST/VIEWER, allows USER/ADMIN', () => { + const { unmount } = renderGuarded('GUEST', canAccessBackend); + expect(screen.queryByText('PROTECTED PAGE')).toBeNull(); + unmount(); + renderGuarded('USER', canAccessBackend); + expect(screen.getByText('PROTECTED PAGE')).toBeTruthy(); + }); +}); diff --git a/packages/web/src/components/RequireRole.tsx b/packages/web/src/components/RequireRole.tsx new file mode 100644 index 00000000..a5f4c99b --- /dev/null +++ b/packages/web/src/components/RequireRole.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import type { Role } from '../lib/roleAccess'; + +// Router-level role guard: if the current user's role fails `can`, redirect home +// instead of mounting the protected page. Defense-in-depth on top of each page's +// own check and the server-side (Worker) authorization. +export function RequireRole({ can, children }: { can: (role: Role) => boolean; children: ReactNode }) { + const { currentUser } = useAuth(); + if (!can(currentUser?.role)) return ; + return <>{children}; +} diff --git a/packages/web/src/lib/roleAccess.test.ts b/packages/web/src/lib/roleAccess.test.ts new file mode 100644 index 00000000..1ce080e8 --- /dev/null +++ b/packages/web/src/lib/roleAccess.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { canAccessAdmin, canAccessBackend } from './roleAccess'; + +describe('roleAccess', () => { + it('canAccessAdmin: only ADMIN', () => { + expect(canAccessAdmin('ADMIN')).toBe(true); + for (const r of ['USER', 'MEMBER', 'VIEWER', 'GUEST', undefined, null, '']) { + expect(canAccessAdmin(r as string)).toBe(false); + } + }); + + it('canAccessBackend: everyone except GUEST and VIEWER', () => { + expect(canAccessBackend('ADMIN')).toBe(true); + expect(canAccessBackend('USER')).toBe(true); + expect(canAccessBackend('MEMBER')).toBe(true); + expect(canAccessBackend('GUEST')).toBe(false); + expect(canAccessBackend('VIEWER')).toBe(false); + }); +}); diff --git a/packages/web/src/lib/roleAccess.ts b/packages/web/src/lib/roleAccess.ts new file mode 100644 index 00000000..fd1bc619 --- /dev/null +++ b/packages/web/src/lib/roleAccess.ts @@ -0,0 +1,8 @@ +export type Role = string | null | undefined; + +// Single source of truth for role-gated routes — used by BOTH the router guard +// (RequireRole) and the sidebar nav, so they can never drift. Mirrors the +// server-side authorization (the Worker independently enforces "Admins only."). +export const canAccessAdmin = (role: Role): boolean => role === 'ADMIN'; + +export const canAccessBackend = (role: Role): boolean => role !== 'GUEST' && role !== 'VIEWER';