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.
1 line
No EOL
3 KiB
JavaScript
1 line
No EOL
3 KiB
JavaScript
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{i as r,o as i,s as a}from"./index-BuU36CvS.js";var o=e(t(),1),s=n(),c=`rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md`,l=`w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60`,u=`w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring`,d=`block text-xs font-semibold text-muted-foreground mb-1`,f=`rounded-md bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 p-3 text-sm text-green-800 dark:text-green-100`,p=`rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3 text-sm text-red-800 dark:text-red-100`,m=`rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-sm text-amber-900 dark:text-amber-100`;function h(){let[e]=a(),t=i(),n=e.get(`token`)||``,[h,g]=(0,o.useState)(``),[_,v]=(0,o.useState)(``),[y,b]=(0,o.useState)(``),[x,S]=(0,o.useState)(``),[C,w]=(0,o.useState)(``),[T,E]=(0,o.useState)(!1);async function D(e){if(e.preventDefault(),b(``),S(``),w(``),!n){b(`Missing reset token. Open the reset link from your email.`);return}if(h.length<8){b(`Password must be 8+ characters`);return}if(h!==_){b(`Passwords do not match`);return}E(!0);try{let e=await r.post(`/api/auth/reset-password`,{token:n,newPassword:h});S(`Password reset. You can now sign in.`),e.passwordWarning&&w(e.passwordWarning),setTimeout(()=>t(`/auth`,{replace:!0}),2500)}catch(e){b(e.message||`Reset failed. The link may have expired.`)}finally{E(!1)}}return(0,s.jsx)(`div`,{className:`min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:from-slate-900 dark:via-slate-950 dark:to-slate-900 flex items-center justify-center p-4`,children:(0,s.jsxs)(`div`,{className:c,children:[(0,s.jsxs)(`header`,{className:`text-center space-y-1`,children:[(0,s.jsx)(`div`,{className:`text-4xl`,children:`🔑`}),(0,s.jsx)(`h1`,{className:`text-xl font-bold`,children:`Set a new password`}),(0,s.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Choose a password at least 8 characters long.`})]}),y&&(0,s.jsx)(`div`,{className:p,children:y}),x&&(0,s.jsx)(`div`,{className:f,children:x}),C&&(0,s.jsx)(`div`,{className:m,children:C}),(0,s.jsxs)(`form`,{onSubmit:D,className:`space-y-3`,"data-testid":`reset-password-form`,children:[(0,s.jsxs)(`div`,{children:[(0,s.jsx)(`label`,{className:d,children:`New password`}),(0,s.jsx)(`input`,{type:`password`,required:!0,minLength:8,className:u,value:h,onChange:e=>g(e.target.value),autoFocus:!0,"data-testid":`reset-new`})]}),(0,s.jsxs)(`div`,{children:[(0,s.jsx)(`label`,{className:d,children:`Confirm new password`}),(0,s.jsx)(`input`,{type:`password`,required:!0,minLength:8,className:u,value:_,onChange:e=>v(e.target.value),"data-testid":`reset-confirm`})]}),(0,s.jsx)(`button`,{type:`submit`,className:l,disabled:T||!n,"data-testid":`reset-submit`,children:T?`Resetting…`:`Reset password`})]})]})})}export{h as default}; |