pediatric-ai-scribe-v3/public/app/assets/Learning-BRd1B0-L.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
13 KiB
JavaScript

import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,l as a,u as o}from"./index-BuU36CvS.js";var s=e(t(),1),c=new Set([`script`,`style`,`iframe`,`object`,`embed`,`link`,`meta`,`base`,`form`,`input`,`button`,`select`,`textarea`]);function l(e){if(c.has(e.tagName.toLowerCase())){e.remove();return}let t=[];for(let n of Array.from(e.attributes)){let e=n.name.toLowerCase(),r=(n.value||``).trim().toLowerCase();(e.startsWith(`on`)||(e===`href`||e===`src`||e===`xlink:href`)&&(r.startsWith(`javascript:`)||r.startsWith(`data:text/html`))||r.includes(`javascript:`))&&t.push(n.name)}t.forEach(t=>e.removeAttribute(t));for(let t of Array.from(e.children))l(t)}function u(e){if(!e)return``;try{let t=new DOMParser().parseFromString(`<!DOCTYPE html><html><body>`+e+`</body></html>`,`text/html`);for(let e of Array.from(t.body.children))l(e);return t.body.innerHTML}catch{return e.replace(/[<>&"']/g,e=>({"<":`&lt;`,">":`&gt;`,"&":`&amp;`,'"':`&quot;`,"'":`&#39;`})[e]||e)}}var d=n(),f=`rounded-lg border border-border bg-card p-5 space-y-3`,p=`px-3 py-1 rounded-full text-xs font-medium border transition-colors cursor-pointer`,m=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`,h=`rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50`,g=`rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50`;function _(e){switch(e){case`quiz`:return`Quiz`;case`pearl`:return`Pearl`;case`presentation`:return`Slides`;default:return`Article`}}function v({row:e,onOpen:t}){return(0,d.jsxs)(`button`,{type:`button`,onClick:t,className:`w-full text-left rounded-lg border border-border bg-card hover:bg-muted/60 p-4 transition-colors`,"data-testid":`lh-feed-item-`+e.slug,children:[(0,d.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide mb-1`,children:[(0,d.jsx)(`span`,{className:`font-semibold`,children:_(e.content_type)}),e.category_name&&(0,d.jsxs)(`span`,{children:[`· `,e.category_name]}),e.question_count?(0,d.jsxs)(`span`,{children:[`· `,e.question_count,` Q`]}):null]}),(0,d.jsx)(`div`,{className:`text-sm font-semibold`,children:e.title}),e.subject&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground mt-0.5 truncate`,children:e.subject})]})}function y({filter:e,query:t,onOpen:n}){let{data:r,isLoading:o,error:s}=a({queryKey:t?[`learning-search`,t]:e?[`learning-category`,e]:[`learning-feed`],queryFn:()=>t?i.get(`/api/learning/search?q=`+encodeURIComponent(t)):e?i.get(`/api/learning/category/`+encodeURIComponent(e)):i.get(`/api/learning/feed?limit=30`)});if(o)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`});if(s)return(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:s.message});let c=r?.content||[];return c.length===0?(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground italic py-4`,children:`No content found.`}):(0,d.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-3`,"data-testid":`lh-feed`,children:c.map(e=>(0,d.jsx)(v,{row:e,onOpen:()=>n(e.slug)},e.id))})}function b(e){let t={};for(let n of e)t[n.id]={optionIds:new Set};return t}function x({content:e,onReset:t}){let n=o(),[a,c]=(0,s.useState)(()=>b(e.questions)),[l,u]=(0,s.useState)(null),[p,m]=(0,s.useState)(null),_=r({mutationFn:e=>i.post(`/api/learning/submit-quiz`,e),onSuccess:t=>{u(t),n.invalidateQueries({queryKey:[`learning-content`,e.slug]})},onError:e=>m(e.message||`Submit failed`)});function v(t){t.preventDefault(),m(null);let n=e.questions.map(e=>{let t=a[e.id];return e.question_type===`multi`?{questionId:e.id,optionIds:Array.from(t?.optionIds||[])}:{questionId:e.id,optionId:t?.optionId??null}});_.mutate({contentId:e.id,answers:n})}function y(e,t){c(n=>({...n,[e.id]:{optionId:t,optionIds:new Set}}))}function x(e,t){c(n=>{let r=new Set(n[e.id]?.optionIds||[]);return r.has(t)?r.delete(t):r.add(t),{...n,[e.id]:{optionIds:r}}})}if(l){let n=l.percentage>=80?`bg-green-600`:l.percentage>=50?`bg-amber-500`:`bg-destructive`;return(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-quiz-results`,children:[(0,d.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Results`}),(0,d.jsxs)(`span`,{className:`px-2 py-0.5 rounded text-xs font-semibold text-white `+n,"data-testid":`lh-quiz-score`,children:[l.score,`/`,l.total,` (`,l.percentage,`%)`]})]}),(0,d.jsx)(`div`,{className:`space-y-3`,children:l.results.map((e,t)=>(0,d.jsxs)(`div`,{className:`rounded-md border border-border p-3 bg-muted/30`,children:[(0,d.jsxs)(`div`,{className:`text-sm font-medium`,children:[(0,d.jsx)(`span`,{className:e.isCorrect?`text-green-600`:`text-destructive`,children:e.isCorrect?``:``}),` `,`Q`,t+1,`: `,e.questionText]}),!e.isCorrect&&e.correctOptionText&&(0,d.jsxs)(`div`,{className:`text-xs text-green-700 mt-1`,children:[(0,d.jsx)(`strong`,{children:`Correct:`}),` `,e.correctOptionText]}),!e.isCorrect&&e.selectedExplanation&&(0,d.jsxs)(`div`,{className:`text-xs text-destructive mt-1`,children:[(0,d.jsx)(`strong`,{children:`Why incorrect:`}),` `,e.selectedExplanation]}),e.generalExplanation&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:e.generalExplanation})]},e.questionId))}),(0,d.jsxs)(`div`,{className:`flex gap-2`,children:[(0,d.jsx)(`button`,{type:`button`,className:g,onClick:()=>{u(null),c(b(e.questions))},children:`Retake`}),(0,d.jsx)(`button`,{type:`button`,className:h,onClick:t,children:`Back to Feed`})]})]})}return(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-quiz`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Quiz`}),(0,d.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[e.questions.length,` question`,e.questions.length===1?``:`s`]})]}),(0,d.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[e.questions.map((e,t)=>{let n=e.question_type===`multi`,r=e.question_type===`true_false`?`True / False`:n?`Multiple Select`:`Single Choice`;return(0,d.jsxs)(`div`,{className:`rounded-md border border-border p-3 space-y-2 bg-muted/30`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between text-xs text-muted-foreground`,children:[(0,d.jsxs)(`span`,{className:`font-semibold`,children:[`Q`,t+1]}),(0,d.jsx)(`span`,{children:r})]}),(0,d.jsx)(`div`,{className:`text-sm font-medium`,children:e.question_text}),n&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground italic`,children:`Select all that apply`}),(0,d.jsx)(`div`,{className:`space-y-1`,children:e.options.map(t=>{let r=a[e.id],i=n?r?.optionIds.has(t.id)===!0:r?.optionId===t.id;return(0,d.jsxs)(`label`,{className:`flex items-start gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-2 py-1`,children:[(0,d.jsx)(`input`,{type:n?`checkbox`:`radio`,name:`q-`+e.id,checked:i,onChange:()=>n?x(e,t.id):y(e,t.id),className:`mt-0.5`}),(0,d.jsx)(`span`,{children:t.option_text})]},t.id)})})]},e.id)}),p&&(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:p}),(0,d.jsx)(`button`,{type:`submit`,className:h,disabled:_.isPending,"data-testid":`btn-lh-submit-quiz`,children:_.isPending?`Submitting…`:`Submit Answers`})]})]})}function S({slug:e,title:t}){let[n,r]=(0,s.useState)(0),[o,c]=(0,s.useState)(!1),{data:l,isLoading:f,error:p}=a({queryKey:[`learning-slides`,e],queryFn:()=>i.get(`/api/learning/content/`+encodeURIComponent(e)+`/slides`)});if((0,s.useEffect)(()=>{function e(e){l&&((e.key===`ArrowRight`||e.key===`PageDown`)&&r(e=>Math.min(e+1,l.slides.length-1)),(e.key===`ArrowLeft`||e.key===`PageUp`)&&r(e=>Math.max(0,e-1)),e.key===`Escape`&&o&&c(!1))}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[l,o]),f)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading slides…`});if(p)return(0,d.jsxs)(`div`,{className:`text-sm text-destructive`,children:[`Failed to load slides: `,p.message]});if(!l||l.slides.length===0)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No slides in this presentation.`});let m=l.css?u(`<style>`+l.css+`</style>`):``,h=u(l.slides[n]||``);return(0,d.jsxs)(`div`,{className:o?`fixed inset-0 z-50 bg-background flex flex-col`:`rounded-lg border border-border bg-white dark:bg-black flex flex-col`,"data-testid":`lh-slides`,children:[m&&(0,d.jsx)(`div`,{dangerouslySetInnerHTML:{__html:m}}),(0,d.jsxs)(`div`,{className:`flex items-center justify-between border-b border-border px-3 py-2 text-xs`,children:[(0,d.jsxs)(`span`,{className:`text-muted-foreground truncate`,children:[`📊 `,t]}),(0,d.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,d.jsxs)(`span`,{children:[n+1,` / `,l.slides.length]}),(0,d.jsx)(`button`,{type:`button`,onClick:()=>c(!o),className:`px-2 py-1 rounded bg-muted text-xs`,"data-testid":`lh-slides-fullscreen`,children:o?`Exit fullscreen`:`Fullscreen`})]})]}),(0,d.jsx)(`div`,{className:`flex-1 overflow-auto p-4 flex items-center justify-center`,style:{minHeight:o?void 0:`480px`},children:(0,d.jsx)(`div`,{dangerouslySetInnerHTML:{__html:h},"data-testid":`lh-slide-current`})}),(0,d.jsxs)(`div`,{className:`flex items-center justify-between border-t border-border px-3 py-2`,children:[(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e=>Math.max(0,e-1)),disabled:n===0,className:g,"data-testid":`lh-slides-prev`,children:`← Previous`}),(0,d.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`← → to navigate`}),(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e=>Math.min(e+1,l.slides.length-1)),disabled:n>=l.slides.length-1,className:g,"data-testid":`lh-slides-next`,children:`Next →`})]})]})}function C({slug:e,onBack:t}){let{data:n,isLoading:r,error:o}=a({queryKey:[`learning-content`,e],queryFn:()=>i.get(`/api/learning/content/`+encodeURIComponent(e))});if(r)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`});if(o)return(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:o.message});if(!n)return null;let s=n.content;return(0,d.jsxs)(`div`,{className:`space-y-4`,children:[(0,d.jsx)(`button`,{type:`button`,className:g,onClick:t,"data-testid":`btn-lh-back`,children:`← Back to Feed`}),(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-viewer`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between gap-4`,children:[(0,d.jsx)(`h2`,{className:`text-xl font-semibold`,"data-testid":`lh-viewer-title`,children:s.title}),(0,d.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[_(s.content_type),s.category_name?` · `+s.category_name:``,s.author_name?` · `+s.author_name:``]})]}),s.content_type===`presentation`?(0,d.jsx)(S,{slug:s.slug,title:s.title}):(0,d.jsx)(`div`,{className:`text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none`,"data-testid":`lh-viewer-body`,dangerouslySetInnerHTML:{__html:u(s.body||``)}})]}),s.progress&&s.progress.length>0&&(0,d.jsxs)(`section`,{className:f,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Your past attempts`}),(0,d.jsx)(`div`,{className:`space-y-1 text-sm`,children:s.progress.map((e,t)=>{let n=e.total>0?Math.round(e.score/e.total*100):0,r=n>=70?`text-green-600`:`text-amber-600`;return(0,d.jsxs)(`div`,{className:`flex justify-between border-b border-border py-1`,children:[(0,d.jsx)(`span`,{children:new Date(e.completed_at).toLocaleDateString()}),(0,d.jsxs)(`span`,{className:`font-semibold `+r,children:[e.score,`/`,e.total,` (`,n,`%)`]})]},t)})})]}),s.questions&&s.questions.length>0&&(0,d.jsx)(x,{content:s,onReset:t})]})}function w(){let[e,t]=(0,s.useState)(``),[n,r]=(0,s.useState)(``),[o,c]=(0,s.useState)(null),{data:l}=a({queryKey:[`learning-categories`],queryFn:()=>i.get(`/api/learning/categories`)});return o?(0,d.jsx)(`div`,{className:`max-w-4xl mx-auto p-6`,children:(0,d.jsx)(C,{slug:o,onBack:()=>c(null)})}):(0,d.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,d.jsxs)(`header`,{children:[(0,d.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Learning Hub`}),(0,d.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Pediatric education, clinical pearls, and self-assessment quizzes.`})]}),(0,d.jsx)(`div`,{className:f,children:(0,d.jsx)(`input`,{type:`search`,className:m,placeholder:`Search topics, subjects…`,value:e,onChange:e=>t(e.target.value),"data-testid":`lh-search`})}),(0,d.jsxs)(`div`,{className:`flex flex-wrap gap-2`,"data-testid":`lh-categories`,children:[(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(``),className:p+(n===``?` bg-primary text-primary-foreground border-primary`:` bg-muted hover:bg-muted/80`),children:`All`}),l?.categories.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e.slug),className:p+(n===e.slug?` bg-primary text-primary-foreground border-primary`:` bg-muted hover:bg-muted/80`),"data-testid":`lh-cat-`+e.slug,children:e.name},e.id))]}),(0,d.jsx)(y,{filter:n,query:e.trim(),onOpen:e=>c(e)})]})}export{w as default};