The final big piece of "everything in React + Tailwind". Login,
register, forgot-password, reset-password, and email-verification all
render from the React bundle now. The root path / serves the SPA,
vanilla index.html + public/js/* are no longer served by the server.
BACKEND — src/routes/auth.ts
New GET /api/auth/public-config (public — no auth required) returns
{ registrationEnabled, turnstileSiteKey, oidcEnabled,
disableLocalAuth, ssoButtonLabel }.
Single round-trip the React auth screen needs on mount. Reuses
existing DB settings; no new tables.
BACKEND — server.ts
• / and /index.html now send public/app/index.html (React SPA),
not public/index.html (vanilla).
• /auth, /reset-password, /verify-email explicitly route to the SPA
so the email links land on the React router.
• /app/*splat preserved as an alias so old bookmarks keep working.
• SPA fallback added after express.static so hard-refresh on
/encounter / /bedside / /settings etc. serves the React index
instead of 404ing. API paths and static-file extensions still
fall through to their existing handlers.
• The dead app.get('/') duplicate that also pointed at the vanilla
index is removed.
CLIENT — React auth flow
client/src/pages/Auth.tsx (new)
Login / register / forgot sub-forms with a single useQuery on
['public-config'] driving Turnstile + SSO button visibility.
Login flow handles all three vanilla-equivalent responses
(token / requires2FA / needsVerification). 2FA field reveals
inline when the server asks for it; resend-verification link
appears when needsVerification fires. SSO button renders
whenever oidcEnabled is true, even if local auth is disabled
(disableLocalAuth hides the login/register/forgot forms
entirely). HIPAA notice + APK download link preserved.
client/src/pages/ResetPassword.tsx (new)
Reads ?token=xxx from the URL, POSTs /api/auth/reset-password.
Confirm-password match, 8+ char validation, server
passwordWarning (pwned password) surfaces as an amber info box.
Redirects to /auth 2.5 s after success.
client/src/components/Turnstile.tsx (new)
Loads the challenges.cloudflare.com/turnstile script once,
renders a widget per form, calls onToken(token) on success and
onToken('') on error / expiry. If siteKey is null/empty (e2e
container with TURNSTILE_SITE_KEY="") renders nothing and
auto-reports empty — matches the vanilla no-key-no-widget
behaviour.
client/src/components/AuthGuard.tsx (new)
useQuery(['auth-me']) with retry: false. On 401/error redirects
to /auth?next=<current-url> so the deep link survives sign-in.
Used as a parent route in App.tsx wrapping every private page.
client/src/components/Layout.tsx
"← back to legacy app" link replaced with "Sign out" — calls
POST /api/auth/logout then window.location = /auth.
client/src/App.tsx
BrowserRouter no longer has basename (was "/app"). Public
routes: /auth, /reset-password. Everything else lives under
<AuthGuard> → <Layout>. Lazy-loaded Auth + ResetPassword join
the existing heavy-route code-split.
client/vite.config.ts
base stays "/app/" so hashed asset URLs resolve to
/app/assets/... (served unchanged by express.static).
shared/types.ts + client/src/shared/types.ts — additive:
PublicConfigOk { registrationEnabled, turnstileSiteKey,
oidcEnabled, disableLocalAuth, ssoButtonLabel }.
Bundle — Auth chunk splits out at 10.87 kB / 3.35 kB gz, lazy-loaded
only on the sign-in path; initial bundle unchanged at 343.97 kB /
106.59 kB gz.
Backend tsc + client tsc + vite build + 136/136 vitest all green.
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
// ============================================================
|
|
// LAYOUT — sidebar + main content shell shared across every page.
|
|
// Structure mirrors the vanilla app so a user moving between the two
|
|
// trees during migration sees consistent navigation.
|
|
// ============================================================
|
|
|
|
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 {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const NAV: NavGroup[] = [
|
|
{
|
|
label: 'Encounters',
|
|
items: [
|
|
{ to: '/encounter', label: 'Encounter HPI', available: true },
|
|
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Notes',
|
|
items: [
|
|
{ to: '/hospital', label: 'Hospital Course', available: true },
|
|
{ to: '/chart', label: 'Chart Review', available: true },
|
|
{ to: '/soap', label: 'SOAP Note', available: true },
|
|
{ to: '/wellvisit', label: 'Well Visit', available: true },
|
|
{ to: '/sickvisit', label: 'Sick Visit', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Clinical Tools',
|
|
items: [
|
|
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
|
|
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
|
|
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
|
|
{ to: '/bedside', label: 'Bedside', available: true },
|
|
{ to: '/calculators', label: 'Calculators', available: true },
|
|
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
|
|
{ to: '/learning', label: 'Learning Hub', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Account',
|
|
items: [
|
|
{ to: '/settings', label: 'Settings', available: true },
|
|
{ to: '/faq', label: 'FAQ', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Admin',
|
|
items: [
|
|
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
function SidebarLink({ item }: { item: NavItem }) {
|
|
if (!item.available) {
|
|
return (
|
|
<div
|
|
className="px-3 py-2 text-sm rounded-md text-muted-foreground italic cursor-not-allowed opacity-60"
|
|
title="Not yet ported to React — still available in the vanilla app at /"
|
|
>
|
|
{item.label} <span className="text-[10px]">· pending</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<NavLink
|
|
to={item.to}
|
|
className={({ isActive }) =>
|
|
'block px-3 py-2 text-sm rounded-md transition-colors ' +
|
|
(isActive
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-muted text-foreground')
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
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 */}
|
|
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
|
|
<div className="px-2 py-1 border-b border-border pb-3 flex items-center justify-between gap-2">
|
|
<div className="font-semibold">Pediatric AI Scribe</div>
|
|
<a
|
|
href="/api/auth/logout"
|
|
onClick={async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
|
} catch { /* ignore */ }
|
|
window.location.href = '/auth';
|
|
}}
|
|
className="text-[11px] text-muted-foreground hover:text-foreground"
|
|
title="Sign out"
|
|
>
|
|
Sign out
|
|
</a>
|
|
</div>
|
|
{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>
|
|
);
|
|
})}
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main className="flex-1 min-w-0">
|
|
{children ?? <Outlet />}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|