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';