From c157c709c8f3b7dc9ec903aacb65043847a185b4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 02:45:19 +0200 Subject: [PATCH] feat: React auth screen + SPA now serves every route (vanilla retired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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 . 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. --- client/src/App.tsx | 82 ++--- client/src/components/AuthGuard.tsx | 30 ++ client/src/components/Layout.tsx | 17 +- client/src/components/Turnstile.tsx | 83 +++++ client/src/pages/Auth.tsx | 310 ++++++++++++++++++ client/src/pages/ResetPassword.tsx | 67 ++++ client/src/shared/types.ts | 10 + .../{Admin-C0HL0O3O.js => Admin-C0fswhTW.js} | 2 +- public/app/assets/Auth-BtfAUU3w.js | 1 + ...edside-Dp0dEd6U.js => Bedside-BsBuEI_m.js} | 2 +- ...rs-DIAUUNQe.js => Calculators-hFcuewWL.js} | 2 +- ...atchup-BtDkOQp-.js => Catchup--869idMv.js} | 2 +- ...ew-D6HIVum0.js => ChartReview-95oCueTc.js} | 2 +- ...tion-9xiYhi4N.js => Dictation-Dk3CctmR.js} | 2 +- ...nter-cVLPC9kX.js => Encounter-CbwLX_2x.js} | 2 +- ...DxLtRVyw.js => HospitalCourse-BeNFIL_Z.js} | 2 +- ...rning-Cgr2RK0s.js => Learning-BRd1B0-L.js} | 2 +- ...eGuide-QOeO6850.js => PeGuide-eWPZEjQC.js} | 2 +- public/app/assets/ResetPassword-pLQAcaAT.js | 1 + ...tings-P_Y5_PdG.js => Settings-CnDpQmnm.js} | 2 +- ...isit-D3m0qxKd.js => SickVisit-vgkhrwdD.js} | 2 +- .../{Soap-CUwNa7mC.js => Soap-VyU-WHem.js} | 2 +- ...le-CHpL4cnR.js => VaxSchedule-WVnLWGX9.js} | 2 +- ...isit-kzdVHevl.js => WellVisit-CQEpKEWj.js} | 2 +- ...{fenton-9EKyvVkL.js => fenton-Dvwmge1q.js} | 0 public/app/assets/index-BEGt1l85.css | 2 + public/app/assets/index-BuU36CvS.js | 50 +++ public/app/assets/index-CyWXS7DQ.css | 2 - public/app/assets/index-DTb5Y4u0.js | 50 --- public/app/index.html | 4 +- server.ts | 56 ++-- shared/types.ts | 10 + src/routes/auth.ts | 21 ++ 33 files changed, 695 insertions(+), 131 deletions(-) create mode 100644 client/src/components/AuthGuard.tsx create mode 100644 client/src/components/Turnstile.tsx create mode 100644 client/src/pages/Auth.tsx create mode 100644 client/src/pages/ResetPassword.tsx rename public/app/assets/{Admin-C0HL0O3O.js => Admin-C0fswhTW.js} (99%) create mode 100644 public/app/assets/Auth-BtfAUU3w.js rename public/app/assets/{Bedside-Dp0dEd6U.js => Bedside-BsBuEI_m.js} (99%) rename public/app/assets/{Calculators-DIAUUNQe.js => Calculators-hFcuewWL.js} (99%) rename public/app/assets/{Catchup-BtDkOQp-.js => Catchup--869idMv.js} (94%) rename public/app/assets/{ChartReview-D6HIVum0.js => ChartReview-95oCueTc.js} (98%) rename public/app/assets/{Dictation-9xiYhi4N.js => Dictation-Dk3CctmR.js} (98%) rename public/app/assets/{Encounter-cVLPC9kX.js => Encounter-CbwLX_2x.js} (98%) rename public/app/assets/{HospitalCourse-DxLtRVyw.js => HospitalCourse-BeNFIL_Z.js} (98%) rename public/app/assets/{Learning-Cgr2RK0s.js => Learning-BRd1B0-L.js} (99%) rename public/app/assets/{PeGuide-QOeO6850.js => PeGuide-eWPZEjQC.js} (99%) create mode 100644 public/app/assets/ResetPassword-pLQAcaAT.js rename public/app/assets/{Settings-P_Y5_PdG.js => Settings-CnDpQmnm.js} (99%) rename public/app/assets/{SickVisit-D3m0qxKd.js => SickVisit-vgkhrwdD.js} (98%) rename public/app/assets/{Soap-CUwNa7mC.js => Soap-VyU-WHem.js} (98%) rename public/app/assets/{VaxSchedule-CHpL4cnR.js => VaxSchedule-WVnLWGX9.js} (94%) rename public/app/assets/{WellVisit-kzdVHevl.js => WellVisit-CQEpKEWj.js} (98%) rename public/app/assets/{fenton-9EKyvVkL.js => fenton-Dvwmge1q.js} (100%) create mode 100644 public/app/assets/index-BEGt1l85.css create mode 100644 public/app/assets/index-BuU36CvS.js delete mode 100644 public/app/assets/index-CyWXS7DQ.css delete mode 100644 public/app/assets/index-DTb5Y4u0.js diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a63c5d..f979657 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,16 +2,15 @@ import { lazy, Suspense } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; import Layout from '@/components/Layout'; +import AuthGuard from '@/components/AuthGuard'; // Lightweight pages stay in the main chunk. import Extensions from '@/pages/Extensions'; import Faq from '@/pages/Faq'; -// Heavy pages lazy-load — keeps the initial bundle small so the home -// screen and quick tools (Extensions, FAQ) paint fast, and users only -// download the big clinical-reference tables (PE_DATA, Fenton LMS, -// Rosner BP splines, Bedside drug panels, Admin sub-tabs) when they -// open those specific routes. +// Heavy pages lazy-load — keeps the initial bundle small. +const Auth = lazy(() => import('@/pages/Auth')); +const ResetPassword = lazy(() => import('@/pages/ResetPassword')); const Dictation = lazy(() => import('@/pages/Dictation')); const Encounter = lazy(() => import('@/pages/Encounter')); const Soap = lazy(() => import('@/pages/Soap')); @@ -41,19 +40,18 @@ function RouteFallback() { function Home() { return (
-

Pediatric AI Scribe — React client

-

- This is the new React tree. The legacy vanilla-JS app still lives at{' '} - /. -

-

Quick links:

+

Pediatric AI Scribe

+

Pick a tool from the sidebar.

  • Encounter HPI
  • Dictation HPI
  • SOAP Note
  • Sick Visit
  • -
  • Extensions & Pagers
  • -
  • FAQ
  • +
  • Well Visit
  • +
  • Physical Exam Guide
  • +
  • Bedside
  • +
  • Calculators
  • +
  • Learning Hub
); @@ -62,31 +60,39 @@ function Home() { export default function App() { return ( - - - }> - } /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - } /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - } /> - {/* catch-all falls back to home while more tabs port over */} - } /> - - + + }> + + {/* Public routes */} + } /> + } /> + + {/* Private routes — AuthGuard + Layout wrap everything */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); diff --git a/client/src/components/AuthGuard.tsx b/client/src/components/AuthGuard.tsx new file mode 100644 index 0000000..51b3d7e --- /dev/null +++ b/client/src/components/AuthGuard.tsx @@ -0,0 +1,30 @@ +// ============================================================ +// AUTH GUARD — redirects to /auth if /api/auth/me returns 401. +// Wraps every private route so unauthenticated users land on +// the login screen automatically. +// ============================================================ + +import { Navigate, useLocation, Outlet } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api'; +import type { MeOk } from '@/shared/types'; + +export default function AuthGuard() { + const loc = useLocation(); + const { data, isLoading, isError } = useQuery({ + queryKey: ['auth-me'], + queryFn: () => api.get('/api/auth/me'), + retry: false, + staleTime: 5 * 60_000, + }); + + if (isLoading) { + return
Loading…
; + } + if (isError || !data?.user) { + // Preserve deep link so we can bounce the user back after sign-in. + const next = encodeURIComponent(loc.pathname + loc.search); + return ; + } + return ; +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index dc3bbc2..c615ae1 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -108,10 +108,21 @@ export default function Layout({ children }: { children?: ReactNode }) {
{/* Sidebar */}