pediatric-ai-scribe-v3/public/app/assets/Auth-BtfAUU3w.js
Daniel c157c709c8 feat: React auth screen + SPA now serves every route (vanilla retired)
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.
2026-04-24 02:45:21 +02:00

1 line
No EOL
11 KiB
JavaScript

import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,l as a,o,u as s}from"./index-BuU36CvS.js";var c=e(t(),1),l=n(),u=`https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad`,d=!1,f=[];function p(){return new Promise(e=>{if(typeof window>`u`){e();return}if(window.turnstile){e();return}if(f.push(e),d)return;d=!0,window.onTurnstileLoad=()=>{for(let e of f.splice(0))e()};let t=document.createElement(`script`);t.src=u,t.async=!0,t.defer=!0,document.head.appendChild(t)})}function m({siteKey:e,onToken:t,action:n,theme:r=`light`}){let i=(0,c.useRef)(null),a=(0,c.useRef)(null);return(0,c.useEffect)(()=>{if(!e){t(``);return}let o=!1;return p().then(()=>{if(!(o||!i.current||!window.turnstile))try{a.current=window.turnstile.render(i.current,{sitekey:e,theme:r,action:n,callback:e=>t(e),"error-callback":()=>t(``),"expired-callback":()=>t(``)})}catch{}}),()=>{if(o=!0,a.current&&window.turnstile)try{window.turnstile.remove(a.current)}catch{}}},[e]),e?(0,l.jsx)(`div`,{ref:i,className:`cf-turnstile my-2`}):null}var h=`rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md`,g=`w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60`,_=`w-full rounded-md bg-slate-900 text-white px-4 py-3 text-sm font-semibold disabled:opacity-60 hover:bg-slate-800`,v=`w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring`,y=`block text-xs font-semibold text-muted-foreground mb-1`,b=`text-sm text-primary hover:underline`,x=`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`,S=`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`,C=`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 w(){let e=o(),t=r();return()=>{e(new URLSearchParams(t.search).get(`next`)||`/`,{replace:!0})}}function T(){let[e,t]=(0,c.useState)(`login`),[n,r]=(0,c.useState)(``),[o,u]=(0,c.useState)(``),[d,f]=(0,c.useState)(``),p=s(),m=w(),{data:g}=a({queryKey:[`public-config`],queryFn:()=>i.get(`/api/auth/public-config`),staleTime:6e4}),v=!!g?.disableLocalAuth;function y(e){t(e),r(``),u(``),f(``)}return(0,l.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,l.jsxs)(`div`,{className:h,children:[(0,l.jsxs)(`header`,{className:`text-center space-y-1`,children:[(0,l.jsx)(`div`,{className:`text-4xl`,children:`🩺`}),(0,l.jsx)(`h1`,{className:`text-xl font-bold`,children:`Pediatric AI Scribe`}),(0,l.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`AI-Powered Clinical Documentation`})]}),n&&(0,l.jsx)(`div`,{className:S,children:n}),d&&(0,l.jsx)(`div`,{className:C,children:d}),o&&(0,l.jsx)(`div`,{className:x,children:o}),e===`login`&&!v&&(0,l.jsx)(E,{cfg:g,onErr:r,onInfo:f,onOk:u,onLogin:()=>{p.invalidateQueries({queryKey:[`auth-me`]}),m()},switchMode:y}),e===`register`&&!v&&(g?.registrationEnabled??!0)&&(0,l.jsx)(D,{cfg:g,onErr:r,onOk:u,switchMode:y}),e===`forgot`&&!v&&(0,l.jsx)(O,{cfg:g,onErr:r,onOk:u,switchMode:y}),g?.oidcEnabled&&(0,l.jsxs)(l.Fragment,{children:[!v&&(0,l.jsxs)(`div`,{className:`relative my-4`,children:[(0,l.jsx)(`div`,{className:`absolute inset-0 flex items-center`,children:(0,l.jsx)(`span`,{className:`w-full border-t border-border`})}),(0,l.jsx)(`div`,{className:`relative flex justify-center text-xs text-muted-foreground`,children:(0,l.jsx)(`span`,{className:`bg-card px-3`,children:`or`})})]}),(0,l.jsxs)(`a`,{href:`/api/auth/oidc`,className:_+` text-center block no-underline`,"data-testid":`auth-oidc`,children:[`🛡️ `,g.ssoButtonLabel||`Sign in with SSO`]})]}),v&&!g?.oidcEnabled&&(0,l.jsx)(`div`,{className:C,children:`Single sign-on not configured. Contact your administrator.`}),(0,l.jsxs)(`div`,{className:`pt-4 border-t border-border text-xs text-muted-foreground space-y-2`,children:[(0,l.jsxs)(`div`,{className:`flex gap-2 items-start`,children:[(0,l.jsx)(`span`,{children:`⚠️`}),(0,l.jsx)(`p`,{children:`HIPAA-compliant AI providers available with BAA. Check your institution's guidelines. Not intended for clinical use without proper authorization.`})]}),(0,l.jsx)(`div`,{className:`text-center`,children:(0,l.jsx)(`a`,{href:`https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest`,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline`,children:`📱 Download Android app (APK)`})})]})]})})}function E({cfg:e,onErr:t,onInfo:n,onOk:r,onLogin:a,switchMode:o}){let[s,u]=(0,c.useState)(``),[d,f]=(0,c.useState)(``),[p,h]=(0,c.useState)(``),[_,x]=(0,c.useState)(!1),[S,C]=(0,c.useState)(!1),[w,T]=(0,c.useState)(``),[E,D]=(0,c.useState)(!1),[O,k]=(0,c.useState)(!1);async function A(o){if(o.preventDefault(),t(``),n(``),r(``),!s||!d){t(`Enter email and password`);return}if(e?.turnstileSiteKey&&!w){t(`Please complete the verification`);return}D(!0);try{let e={email:s,password:d};w&&(e.turnstileToken=w),p&&(e.totpCode=p);let o=await i.post(`/api/auth/login`,e);if(o.requires2FA){x(!0),n(`Enter your 2FA code`),D(!1);return}if(o.needsVerification){C(!0),t(`Verify your email first. Check your inbox.`),D(!1);return}if(o.token){r(`Signed in`),a();return}t(`Login failed`)}catch(e){t(e.message||`Login failed`)}finally{D(!1)}}async function j(){if(!s){t(`Enter your email first`);return}k(!0);try{r((await i.post(`/api/auth/resend-verification`,{email:s})).message||`Verification email sent`)}catch(e){t(e.message||`Failed to resend`)}finally{k(!1)}}return(0,l.jsxs)(`form`,{onSubmit:A,className:`space-y-3`,"data-testid":`auth-login-form`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold text-center`,children:`Sign in`}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Email`}),(0,l.jsx)(`input`,{type:`email`,autoFocus:!0,required:!0,className:v,value:s,onChange:e=>u(e.target.value),"data-testid":`auth-email`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Password`}),(0,l.jsx)(`input`,{type:`password`,required:!0,className:v,value:d,onChange:e=>f(e.target.value),"data-testid":`auth-password`})]}),_&&(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`2FA code`}),(0,l.jsx)(`input`,{type:`text`,inputMode:`numeric`,pattern:`[0-9]*`,maxLength:6,className:v+` font-mono tracking-widest`,value:p,onChange:e=>h(e.target.value.replace(/\D/g,``)),"data-testid":`auth-totp`,autoFocus:!0})]}),(0,l.jsx)(m,{siteKey:e?.turnstileSiteKey,onToken:T}),(0,l.jsx)(`button`,{type:`submit`,className:g,disabled:E,"data-testid":`auth-submit`,children:E?`Signing in…`:_?`Verify 2FA`:`Sign in`}),S&&(0,l.jsx)(`div`,{className:`text-center`,children:(0,l.jsx)(`button`,{type:`button`,onClick:j,className:b,disabled:O,"data-testid":`auth-resend-verify`,children:O?`Sending…`:`Resend verification link`})}),(0,l.jsxs)(`div`,{className:`flex justify-between text-sm`,children:[(e?.registrationEnabled??!0)&&(0,l.jsx)(`button`,{type:`button`,onClick:()=>o(`register`),className:b,"data-testid":`auth-show-register`,children:`Create account`}),(0,l.jsx)(`button`,{type:`button`,onClick:()=>o(`forgot`),className:b+` ml-auto`,"data-testid":`auth-show-forgot`,children:`Forgot password?`})]})]})}function D({cfg:e,onErr:t,onOk:n,switchMode:r}){let[a,o]=(0,c.useState)(``),[s,u]=(0,c.useState)(``),[d,f]=(0,c.useState)(``),[p,h]=(0,c.useState)(``),[_,x]=(0,c.useState)(!1);async function S(o){if(o.preventDefault(),t(``),n(``),!a||!s||d.length<8){t(`Fill all fields (password 8+ chars)`);return}if(e?.turnstileSiteKey&&!p){t(`Please complete the verification`);return}x(!0);try{let e={name:a,email:s,password:d};p&&(e.turnstileToken=p);let t=await i.post(`/api/auth/register`,e);t.needsVerification?n(`Account created. Check your email to verify.`):t.token?(n(`Account created. Signing you in…`),setTimeout(()=>{window.location.href=`/`},800)):n(t.message||`Account created`),r(`login`)}catch(e){t(e.message||`Registration failed`)}finally{x(!1)}}return(0,l.jsxs)(`form`,{onSubmit:S,className:`space-y-3`,"data-testid":`auth-register-form`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold text-center`,children:`Create account`}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Full name`}),(0,l.jsx)(`input`,{type:`text`,required:!0,autoFocus:!0,className:v,value:a,onChange:e=>o(e.target.value),"data-testid":`auth-reg-name`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Email`}),(0,l.jsx)(`input`,{type:`email`,required:!0,className:v,value:s,onChange:e=>u(e.target.value),"data-testid":`auth-reg-email`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Password (8+ characters)`}),(0,l.jsx)(`input`,{type:`password`,required:!0,minLength:8,className:v,value:d,onChange:e=>f(e.target.value),"data-testid":`auth-reg-password`})]}),(0,l.jsx)(m,{siteKey:e?.turnstileSiteKey,onToken:h}),(0,l.jsx)(`button`,{type:`submit`,className:g,disabled:_,"data-testid":`auth-reg-submit`,children:_?`Creating…`:`Create account`}),(0,l.jsx)(`div`,{className:`text-center`,children:(0,l.jsx)(`button`,{type:`button`,onClick:()=>r(`login`),className:b,children:`Back to sign in`})})]})}function O({cfg:e,onErr:t,onOk:n,switchMode:r}){let[a,o]=(0,c.useState)(``),[s,u]=(0,c.useState)(``),[d,f]=(0,c.useState)(!1);async function p(r){if(r.preventDefault(),t(``),n(``),!a){t(`Enter your email`);return}if(e?.turnstileSiteKey&&!s){t(`Please complete the verification`);return}f(!0);try{let e={email:a};s&&(e.turnstileToken=s),n((await i.post(`/api/auth/forgot-password`,e)).message||`If an account exists, a reset link was sent.`)}catch(e){t(e.message||`Request failed`)}finally{f(!1)}}return(0,l.jsxs)(`form`,{onSubmit:p,className:`space-y-3`,"data-testid":`auth-forgot-form`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold text-center`,children:`Reset password`}),(0,l.jsx)(`p`,{className:`text-xs text-muted-foreground text-center`,children:`Enter your email and we'll send a reset link.`}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:y,children:`Email`}),(0,l.jsx)(`input`,{type:`email`,required:!0,autoFocus:!0,className:v,value:a,onChange:e=>o(e.target.value),"data-testid":`auth-forgot-email`})]}),(0,l.jsx)(m,{siteKey:e?.turnstileSiteKey,onToken:u}),(0,l.jsx)(`button`,{type:`submit`,className:g,disabled:d,"data-testid":`auth-forgot-submit`,children:d?`Sending…`:`Send reset link`}),(0,l.jsx)(`div`,{className:`text-center`,children:(0,l.jsx)(`button`,{type:`button`,onClick:()=>r(`login`),className:b,children:`Back to sign in`})})]})}export{T as default};