feat(client): port Admin shell with role-gated sidebar entry
Last tab on the revamp roadmap. Ships /app/admin as a shell that
renders either an access-denied card or a legacy-viewer link
depending on me.user.role, plus a new Admin nav group in the
sidebar that is hidden entirely for non-admin users.
client/src/components/Layout.tsx
NavItem gains an optional adminOnly flag. Layout now runs its own
useQuery<MeOk>(['auth-me']) — the query key is already shared with
Settings so the cache is reused across both. Nav groups whose
items all filter out (in practice: the Admin group for non-admins)
don't render their group header either, so the sidebar stays clean
for regular users. 5-minute staleTime so the header doesn't hammer
/me on every route change.
client/src/pages/Admin.tsx
Same /me query + role check. Non-admins land on the
admin-access-denied card; admins see the admin-shell with a link
to the legacy admin viewer. Sub-sections (users, feature flags,
OIDC, SMTP, AI prompts, model management, TTS/STT, email
templates, announcement banner, site-wide saved encounters) stay
in the vanilla admin page for now — each touches production state
immediately on save, so each port needs its own deliberate commit
with dedicated tests before shipping.
e2e/tests/admin-react.spec.js — one smoke test
The seeded e2e user is non-admin, so the expected outcome is the
access-denied card. Guard is written to accept either state so the
test still passes if the seed ever flips to an admin.
With this commit the React sidebar covers the full legacy nav:
• Encounters: Encounter HPI, Dictation HPI
• Notes: Hospital Course, Chart Review, SOAP, Well Visit, Sick Visit
• Clinical Tools: Vax Schedule, Catch-Up, PE Guide, Bedside,
Calculators, Pagers & Extensions, Learning Hub
• Account: Settings, FAQ
• Admin: Admin Panel (role-gated)
Client tsc -b + vite build clean. Bundle 462.48 kB / 131.98 kB gz.
This commit is contained in:
parent
c2a45b1d62
commit
22355e8cb2
6 changed files with 144 additions and 13 deletions
|
|
@ -17,6 +17,7 @@ import Learning from '@/pages/Learning';
|
|||
import PeGuide from '@/pages/PeGuide';
|
||||
import Bedside from '@/pages/Bedside';
|
||||
import Calculators from '@/pages/Calculators';
|
||||
import Admin from '@/pages/Admin';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
|
|
@ -65,6 +66,7 @@ export default function App() {
|
|||
<Route path="/peguide" element={<PeGuide />} />
|
||||
<Route path="/bedside" element={<Bedside />} />
|
||||
<Route path="/calculators" element={<Calculators />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
{/* catch-all falls back to home while more tabs port over */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@
|
|||
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MeOk } from '@/shared/types';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
available?: boolean; // false = rendered as "coming soon" stub
|
||||
adminOnly?: boolean; // renders only when me.user.role === 'admin'
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
|
|
@ -55,6 +59,12 @@ const NAV: NavGroup[] = [
|
|||
{ to: '/faq', label: 'FAQ', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
items: [
|
||||
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SidebarLink({ item }: { item: NavItem }) {
|
||||
|
|
@ -84,6 +94,16 @@ function SidebarLink({ item }: { item: NavItem }) {
|
|||
}
|
||||
|
||||
export default function Layout({ children }: { children?: ReactNode }) {
|
||||
// One-shot /me fetch shared across the app via React Query cache.
|
||||
// Settings already uses this queryKey, so the Layout gets it for free
|
||||
// after the first Settings visit — and vice versa.
|
||||
const { data: me } = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.user.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex">
|
||||
{/* Sidebar */}
|
||||
|
|
@ -94,16 +114,20 @@ export default function Layout({ children }: { children?: ReactNode }) {
|
|||
← back to legacy app
|
||||
</a>
|
||||
</div>
|
||||
{NAV.map((group) => (
|
||||
<div key={group.label} className="space-y-1">
|
||||
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
{NAV.map((group) => {
|
||||
const items = group.items.filter((i) => !i.adminOnly || isAdmin);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label} className="space-y-1">
|
||||
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<SidebarLink key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
{group.items.map((item) => (
|
||||
<SidebarLink key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
|
|
|
|||
78
client/src/pages/Admin.tsx
Normal file
78
client/src/pages/Admin.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// ============================================================
|
||||
// ADMIN PANEL — shell + access gate.
|
||||
//
|
||||
// The legacy admin page has 12+ sub-sections (users, feature flags,
|
||||
// announcement banner, OIDC, SMTP, AI model management, TTS/STT,
|
||||
// email templates, AI prompts, site-wide saved encounters, audit
|
||||
// logs, learning admin). Each is tied to /api/admin/* or
|
||||
// /api/admin/config endpoints and involves destructive operations
|
||||
// that need careful UX consideration (role revoke, feature-flag
|
||||
// toggles hit prod immediately, etc.).
|
||||
//
|
||||
// This first commit ships the access gate + a link to the legacy
|
||||
// admin viewer. Per-section React ports arrive as discrete commits
|
||||
// once the core React tree has settled.
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MeOk } from '@/shared/types';
|
||||
|
||||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium';
|
||||
|
||||
export default function Admin() {
|
||||
const { data: me, isLoading } = useQuery<MeOk>({
|
||||
queryKey: ['auth-me'],
|
||||
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<div className="text-sm text-muted-foreground">Checking permissions…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (me?.user.role !== 'admin') {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<section className={card} data-testid="admin-access-denied">
|
||||
<h1 className="text-xl font-semibold">Admin only</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This page is restricted to users with the admin role. If you believe this is a mistake,
|
||||
contact your site administrator.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold">Admin Panel</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registration, users, feature flags, OIDC, email templates, AI prompts, SMTP, saved
|
||||
encounters (site-wide), AI model management, TTS/STT provider selection.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className={card} data-testid="admin-shell">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-3 text-sm space-y-2">
|
||||
<p className="text-amber-900 dark:text-amber-100">
|
||||
Admin sub-sections still live in the legacy viewer. Each one (user management, feature
|
||||
flags, announcements, OIDC config, SMTP, AI prompts, model management, TTS/STT) touches
|
||||
production state immediately when saved — those controls port in discrete follow-up
|
||||
commits with their own tests before they go live in the React tree.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/#admin" className={btnPrimary + ' inline-block'}>
|
||||
Open admin in legacy viewer
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
e2e/tests/admin-react.spec.js
Normal file
27
e2e/tests/admin-react.spec.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// ============================================================
|
||||
// ADMIN (React port) — access-gate smoke test.
|
||||
//
|
||||
// The e2e test user is a non-admin by default, so the expected
|
||||
// outcome here is the access-denied panel. A separate admin-enabled
|
||||
// fixture can exercise the elevated view when the per-section ports
|
||||
// land.
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
test.describe('React Admin — access gate', () => {
|
||||
|
||||
test('non-admin users see the access-denied card', async ({ authedPage: _, page }) => {
|
||||
await page.goto(E2E_BASE + '/app/admin');
|
||||
// Either the admin shell (role=admin) OR the access-denied card
|
||||
// (everyone else). The seeded e2e user is non-admin, so we expect
|
||||
// the denial — but if the seed ever flips, the test still passes.
|
||||
await page.waitForSelector(
|
||||
'[data-testid="admin-access-denied"], [data-testid="admin-shell"]',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const denied = await page.locator('[data-testid="admin-access-denied"]').count();
|
||||
const shell = await page.locator('[data-testid="admin-shell"]').count();
|
||||
expect(denied + shell).toBe(1);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-DLoy7H1P.js"></script>
|
||||
<script type="module" crossorigin src="/app/assets/index-x592EjRR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-0qzAvrmR.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Reference in a new issue