Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -107,8 +109,8 @@ function AuthenticatedApp() {
<Route path="/agents" element={<Agents />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="/backend" element={<Backend />} />
<Route path="/admin" element={<RequireRole can={canAccessAdmin}><Admin /></RequireRole>} />
<Route path="/backend" element={<RequireRole can={canAccessBackend}><Backend /></RequireRole>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
Expand Down
10 changes: 7 additions & 3 deletions packages/web/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/components/MobileBottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) =>
Expand Down
53 changes: 53 additions & 0 deletions packages/web/src/components/RequireRole.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route path="/" element={<div>HOME</div>} />
<Route path="/admin" element={<RequireRole can={can}><div>PROTECTED PAGE</div></RequireRole>} />
</Routes>
</MemoryRouter>,
);
}

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();
});
});
13 changes: 13 additions & 0 deletions packages/web/src/components/RequireRole.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/" replace />;
return <>{children}</>;
}
19 changes: 19 additions & 0 deletions packages/web/src/lib/roleAccess.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 8 additions & 0 deletions packages/web/src/lib/roleAccess.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading