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
35 changes: 21 additions & 14 deletions packages/web/src/components/RequireRole.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { RequireRole } from './RequireRole';
import { canAccessAdmin, canAccessBackend } from '../lib/roleAccess';
Expand All @@ -10,6 +10,11 @@ vi.mock('../contexts/AuthContext', () => ({
useAuth: () => ({ currentUser: mockRole === undefined ? null : { role: mockRole } }),
}));

// Tear down the DOM after every test (and between in-loop renders below) so a
// prior render's "PROTECTED PAGE" can never bleed into the next assertion — the
// redirect is exactly what we're verifying.
afterEach(() => cleanup());

function renderGuarded(role: string | undefined | null, can: (r: any) => boolean) {
mockRole = role;
return render(
Expand All @@ -23,31 +28,33 @@ function renderGuarded(role: string | undefined | null, can: (r: any) => boolean
}

describe('RequireRole (router guard)', () => {
it('renders the protected page for an allowed role', () => {
it('renders the protected page for an allowed role', async () => {
renderGuarded('ADMIN', canAccessAdmin);
expect(screen.getByText('PROTECTED PAGE')).toBeTruthy();
expect(await screen.findByText('PROTECTED PAGE')).toBeTruthy();
});

it('redirects disallowed roles (GUEST/VIEWER/USER) away from /admin', () => {
it('redirects disallowed roles (GUEST/VIEWER/USER) away from /admin', async () => {
for (const r of ['GUEST', 'VIEWER', 'USER']) {
const { unmount } = renderGuarded(r, canAccessAdmin);
renderGuarded(r, canAccessAdmin);
// <Navigate> resolves to HOME; PROTECTED PAGE must never render.
expect(await screen.findByText('HOME')).toBeTruthy();
expect(screen.queryByText('PROTECTED PAGE')).toBeNull();
expect(screen.getByText('HOME')).toBeTruthy();
unmount();
cleanup();
}
});

it('redirects when unauthenticated (no user)', () => {
it('redirects when unauthenticated (no user)', async () => {
renderGuarded(undefined, canAccessAdmin);
expect(await screen.findByText('HOME')).toBeTruthy();
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);
it('backend guard: blocks GUEST/VIEWER, allows USER/ADMIN', async () => {
renderGuarded('GUEST', canAccessBackend);
expect(await screen.findByText('HOME')).toBeTruthy();
expect(screen.queryByText('PROTECTED PAGE')).toBeNull();
unmount();
cleanup();
renderGuarded('USER', canAccessBackend);
expect(screen.getByText('PROTECTED PAGE')).toBeTruthy();
expect(await screen.findByText('PROTECTED PAGE')).toBeTruthy();
});
});
20 changes: 9 additions & 11 deletions packages/web/src/pages/Admin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { Users, Database, Shield, Download, Upload, Settings2, RefreshCw, AlertCircle, Lock, Key, Globe, CheckCircle, XCircle, AlertTriangle, FileText, Calendar, Server, Network, Copy, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { canAccessAdmin } from '../lib/roleAccess';
import { AdminUserManagement } from '../components/AdminUserManagement';
import { CustomDropdown } from '../components/CustomDropdown';
import { APP_VERSION } from '../utils/version';
Expand All @@ -12,17 +14,13 @@ export function Admin() {
const { currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('users');

// Redirect if not ADMIN
if (currentUser?.role !== 'ADMIN') {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Shield className="h-16 w-16 text-red-400 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1>
<p className="text-gray-400">Only ADMIN users can access the Admin panel.</p>
</div>
</div>
);
// Hard lockout for non-admins: redirect away rather than render any admin
// chrome/tabs. This is defense-in-depth — the /admin route is already gated by
// <RequireRole can={canAccessAdmin}> and the Worker rejects admin operations
// server-side — but the page also fails closed on its own so admin UI can
// never mount for a non-admin even if the route guard is ever changed.
if (!canAccessAdmin(currentUser?.role)) {
return <Navigate to="/" replace />;
}

const tabs = [
Expand Down
Loading