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.
Closes the last two Learning Hub follow-ups flagged in the earlier
commit body: rich-HTML body rendering (instead of pre-wrap plain text)
and in-React Marp slide playback (instead of the legacy-viewer link).
client/src/lib/sanitize.ts (new)
Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts
run), walks the tree and:
• Removes script / style / iframe / object / embed / link / meta /
base / form / input / button / select / textarea.
• Drops every on* event-handler attribute.
• Drops href / src / xlink:href values starting with javascript:
or data:text/html.
• Drops any attribute whose value contains "javascript:".
• Falls back to entity-escaping the raw string if parsing throws.
Admin-authored Learning Hub content is the trust model here —
essentially CMS content. A full DOMPurify dep would be strictly
better but adding a package requires a network install; the inline
sanitizer covers the realistic XSS vectors without the dep bump.
client/src/pages/Learning.tsx
• Body rendering (non-presentation content_type) now runs through
sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm`
Tailwind-typography class. Markdown/HTML formatting from admin
content now appears correctly (headings, lists, code, bold,
italics, links) instead of raw text.
• SlideViewer component (new) replaces the "Open in legacy viewer"
button for content_type === 'presentation'. Fetches
/api/learning/content/:slug/slides (returns { css, slides[] } —
server-side Marp output), sanitizes each slide's HTML + the CSS
block, renders one slide at a time with:
- prev/next buttons
- keyboard ←/→ and PageUp/PageDown navigation
- slide counter (N/total)
- fullscreen toggle (Escape exits)
Marp's own CSS is injected scoped-ish via sanitizer so slide
theming survives.
shared/types.ts + client/src/shared/types.ts — additive:
LearningSlidesOk { css: string; slides: string[] }
Client tsc + vite build clean. Initial bundle unchanged
(342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded;
the sanitizer + SlideViewer roll into its chunk.
Replaces the legacy-link shell at /app/admin with a proper
multi-sub-tab admin panel. Covers every module the vanilla admin.js
exposed: Users, Site settings, Announcement banner, AI models,
TTS / STT providers, SMTP, Email templates, AI prompts, Audit logs.
Priority 1 per Daniel's note: **Users**. Full CRUD flow ported —
list (with live filter), verify, disable/enable, set role
(user/moderator/admin), delete (with confirm modal), admin-side
password reset (inline modal). Self-protection rules preserved:
can't disable/delete yourself, can't demote your own admin role.
client/src/pages/Admin.tsx (rewritten)
Sub-tab shell. Role check via useQuery(['auth-me']) reusing the
Layout cache. 10 pills drive which panel renders. Access-denied
card for non-admins (data-testid='admin-access-denied' unchanged).
client/src/pages/AdminPanels.tsx (new) — batch 1
• AdminUsersTab — useQuery ['admin-users'] + 6 mutations
(verify / disable / enable / set role / delete / reset password).
Color-coded rows (disabled users opacity-60), inline role select,
inline search filter over email+name.
• AdminSettingsTab — GET /api/admin/settings → stats (totalUsers /
totalApiCalls / todayApiCalls) + registration toggle.
• AdminAnnouncementTab — reads announcement.{enabled,type,text} via
/api/admin/config/announcement, saves via 3 parallel PUT
/api/admin/config/<key> calls. Info/Warning/Critical severity
select; text rendered in the top-of-page banner.
client/src/pages/AdminPanels2.tsx (new) — batch 2
• AdminSmtpTab — host/port/user/pass/from/secure form + source
badge (env / database / none). PUT /api/admin/config/smtp,
DELETE /api/admin/config/smtp (with ConfirmModal). Inline test
email sender (recipient + template) calling
POST /api/admin/config/test-email.
• AdminEmailTab — template selector (verify / reset /
password-changed), subject + HTML body textarea. Pulls values
from /api/admin/config, saves via 2 parallel PUT calls.
• AdminPromptsTab — GET /api/admin/config/prompts populates the
selector; textarea edits the active prompt; save via
PUT /api/admin/config/prompt.<key>; reset-to-default via
POST /api/admin/config/prompts/<key>/reset with ConfirmModal.
• AdminModelsTab — GET /api/admin/config/models renders the
provider-scoped model table with per-row Enabled checkbox +
Default radio. Mutations hit /api/admin/config/models/toggle
and /default. LiteLLM + model-discovery flows flagged as
legacy-viewer follow-up.
• AdminTtsTab / AdminSttTab — show active provider + voice/model
selector, save default via PUT /api/admin/config/tts.default_voice
and /stt.default_model respectively.
• AdminLogsTab — GET /api/admin/logs/all?category=&limit= with
sticky-header scrollable table. Category filter
(auth / admin / clinical / export / integration / documents) and
limit selector (50-500). Renders time / user / category /
action / detail / IP per row.
shared/types.ts + client/src/shared/types.ts — additive only:
AdminUser, AdminUsersOk, AdminUserOk, AdminSettingsOk,
AdminLogEntry, AdminLogsOk, AdminConfigRow, AdminConfigOk,
AdminAnnouncementOk, AdminPromptRow, AdminPromptsOk,
AdminSmtpStatusOk, AdminModelRow, AdminModelsOk,
AdminVoiceProviderOk.
With this commit the React sidebar covers the full legacy nav.
Access-control is preserved (Admin is still role-gated and the nav
link hides for non-admins). Every existing backend endpoint is
reused as-is — no server changes.
Backend tsc + client tsc + vite build + 136/136 vitest all green.
Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.
client/src/pages/Learning.tsx
Three-screen flow: search + category pills drive a feed grid; clicking
a card opens the viewer; viewer shows body + progress + quiz (if any).
Feed: one query key per filter — ['learning-feed'], ['learning-category',
slug], or ['learning-search', q] — so React Query caches each view
independently and flicking between categories is instant after the first
load. Search hits /api/learning/search; category filter hits
/api/learning/category/:slug; default hits /api/learning/feed?limit=30.
Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
the client bundle is a follow-up. Authored content is still clinical
info, so plain-text preservation is acceptable for this commit — no
content is lost, just unstyled. Presentations (content_type === 'presentation')
link to the legacy viewer at /#learning/:slug — Marp slide rendering is
its own port.
Quiz: supports single-choice, multi-select, and true/false. Answers
tracked via { optionId?, optionIds: Set<number> } per question so the
same state shape drives both radio and checkbox rendering. Submit POSTs
to /api/learning/submit-quiz; results screen shows per-question verdict
with correct answer + why-incorrect + general explanation — same fields
the vanilla showQuizResults renders. Retake wipes the answer map;
Back-to-Feed returns to the list.
Progress list reads content.progress[] directly from the content response
— last 5 attempts, color-coded green/amber at 70%.
shared/types.ts + client/src/shared/types.ts — additive:
LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
the wire shape server routes return (snake_case for DB columns).
e2e/tests/learning-react.spec.js — three smoke tests:
shell renders, feed shows items OR empty-state (no crash on empty DB),
typing into search fires /api/learning/search.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
Third and final commit of the Settings port. Adds the remaining eight
sub-sections so the React page matches vanilla settings.html 1:1.
After this commit Settings is fully ported; Layout already flipped to
available in commit 1, and the page fills out cleanly for local-auth
and SSO users alike.
Voice Preferences (VoicePreferencesCard)
GET /api/user/preferences + /api/user/preferences/options populate the
STT model / TTS voice selectors. Save POSTs /api/user/preferences.
Preview persists the current TTS selection, then fetches /api/text-to-
speech (binary blob, bypasses the JSON api wrapper), wraps the blob in
an Audio element and plays it. A one-shot hydrated flag drives the
first selection sync; after that the fields are local state.
Browser Whisper (BrowserWhisperCard) — UI-only port
Persists the enabled flag + model choice under the same localStorage
keys the vanilla BrowserWhisper module reads, so behavior will light
up automatically when the recording components port. The preload +
WASM transcription flow stays in vanilla for this commit — noted in
the page copy so users aren't surprised.
Web Speech Recognition (WebSpeechCard) — UI-only port
Same localStorage approach. Enabling surfaces a styled ConfirmModal
with the HIPAA privacy warning before persisting. Enabling Web Speech
flips Browser Whisper off automatically (mirrors vanilla priority:
Web Speech > Browser Whisper > server).
My Templates (TemplatesCard)
Full Memories CRUD for non-correction entries: category select,
name, content textarea, Add/Update toggle (in-place edit), per-row
Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}.
AI Corrections (CorrectionsCard)
Read-only list filtered to category starting with 'correction_'.
Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split
(same text delimiter the vanilla parseCorrection() uses). Delete is
wired through /api/memories/:id.
Audio Backups (AudioBackupsCard)
Lists /api/audio-backups (server-stored, 24h TTL). Play opens the
decompressed audio stream in a new tab; Delete hits DELETE
/api/audio-backups/:id. Retry flow stays in vanilla for this commit —
it re-submits to /api/transcribe and that integration belongs with
the recording components.
Saved Encounters (SavedEncountersCard)
Lists /api/encounters/saved with label / type / expires / preview.
Delete only — Resume requires the encounter pages to receive
pre-filled state, which ports alongside those pages.
Compliance (ComplianceCard)
Static info card — plain JSX, no API.
shared/types.ts + client/src/shared/types.ts — additive only:
UserPreferencesOk, PreferencesOptionsOk, VoiceOption,
SavedEncounterRow, SavedEncountersListOk, AudioBackupRow,
AudioBackupsListOk, MemoryRow, MemoriesOk.
e2e/tests/settings-react-voice-content.spec.js — seven smoke tests
covering control presence, templates empty-save validation, and the
Web Speech privacy-confirm modal (with the no-native-dialog guard).
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2).
The e2e container still predates /app/*; running these specs against
it needs a rebuild.
Second of three commits porting the vanilla settings.html. This one
delivers the two Integrations sub-sections, rendered below the Security
block and shown to every authenticated user (not gated by canLocalAuth —
SSO users also integrate Nextcloud and manage documents).
client/src/pages/Settings.tsx — NextcloudCard
Form + status line driven by /api/auth/me. Connect POSTs
/api/nextcloud/connect (nextcloudUrl / username / appPassword), which
does a PROPFIND probe against the remote, creates the target folder
via MKCOL, and encrypts the app password at rest. On success we
invalidate the ['auth-me'] query so the status line flips to
"Connected to …" without a reload. Disconnect goes through a
ConfirmModal (not a native confirm) and POSTs /api/nextcloud/disconnect.
When connected, a second row exposes the "Learning Hub — Default
Browse Path" input backed by POST /api/user/webdav-path. (That handler
lives inline in server.ts, not in userPreferences.ts — a quirk of the
existing codebase that the port preserves.)
client/src/pages/Settings.tsx — DocumentsCard
React Query feed off /api/documents. When S3 is not configured the
server returns { s3_configured: false } and we render a static notice
instead of the upload area (same branch as vanilla documents.js). The
upload form bypasses the JSON api wrapper to send multipart FormData
directly via fetch with credentials: 'include' (cookie auth continues
to work). Downloads hit /api/documents/:id/download to receive a 5-min
presigned URL which we open in a new tab. Delete goes through the
shared ConfirmModal — replaces the vanilla showConfirm({ danger, … }).
Downloading-state spinner is per-row (useMutation.variables === doc.id)
so other rows stay clickable while one is in flight.
shared/types.ts + client/src/shared/types.ts
Additive only:
- AuthUser gains webdav_learning_path — already returned by
/api/auth/me but missing from the type.
- New response shapes: NextcloudConnectOk, UserDocument,
DocumentsListOk, DocumentUploadOk, DocumentDownloadOk.
e2e/tests/settings-react-integrations.spec.js
Four smoke tests: field presence, empty-form validation error, the
S3-configured-or-notice branch renders, and a repeat of the
no-native-dialog guard covering the Integrations interactions.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 404.56 kB / 117.12 kB gzipped (+9 kB over commit 1). The e2e
container still predates /app/*; running these specs needs a rebuild.
First of three commits porting the vanilla settings.html (13 sub-sections
total) to the React tree. This commit delivers the Settings page shell
plus the three Security sub-sections. Integrations (Nextcloud, Documents)
and Voice + Content land in the two follow-ups.
client/src/pages/Settings.tsx
Page shell that fetches /api/auth/me and conditionally renders the
local-auth sections only when user.canLocalAuth !== false. SSO-only
users see a brief "managed by your identity provider" notice instead —
matches vanilla behavior, which hides those cards for SSO accounts.
Change Password: three-field form (current / new / confirm) with
client-side validation (8+ chars, match). POSTs /api/auth/change-password.
On success the server destroys all OTHER sessions, so the component
invalidates the ['sessions'] query so the Active Sessions card below
refreshes without a full reload. passwordWarning (pwned-password hint)
surfaces as a follow-up info toast.
Two-Factor Auth: status line ("Enabled" / "Not enabled") reads
user.totp_enabled. Enable button POSTs /api/auth/setup-2fa, renders the
returned QR + secret, accepts the 6-digit code and POSTs /verify-2fa.
First-enable shows a one-shot BackupCodesDisplay modal with Copy + close.
Disable flow is inline (password field + Confirm Disable + Cancel, no
modal) — matches the vanilla UX. Backup-codes remaining count pulls
from /api/auth/2fa/backup-codes/count; a Regenerate button opens a
ConfirmModal with requirePassword=true and POSTs /2fa/backup-codes.
Active Sessions: useQuery on /api/sessions renders one row per session
with the current one highlighted. Per-row Revoke opens a ConfirmModal;
Revoke All Other Sessions opens another ConfirmModal. Both DELETE calls
invalidate ['sessions'] on success.
client/src/components/ConfirmModal.tsx
Reusable styled confirmation dialog — replaces vanilla showConfirm().
Supports a danger variant (destructive button styling) and an optional
password-input variant for confirm-by-password flows. Escape closes,
backdrop click closes, Enter in the password field submits. Carries
data-testid hooks (confirm-modal-ok, confirm-modal-cancel) so Playwright
can drive it without ever hitting window.confirm().
shared/types.ts + client/src/shared/types.ts
Additive changes only:
- AuthUser gains optional canLocalAuth, totp_enabled, email_verified,
nextcloud_url/user/folder, created_at — all fields the server
already returns from /api/auth/me but the type had never described.
- SessionRow reshape to match the wire format the server actually
returns (snake_case ip_address / device_label / created_at /
last_activity), replacing the speculative camelCase draft. No
existing consumer of SessionRow existed outside Settings, so the
rename is a no-op for current code.
- New types for 2FA + change-password response shapes (Setup2faOk,
Verify2faOk, BackupCodesCountOk, RegenBackupCodesOk,
ChangePasswordOk, RevokeAllSessionsOk).
client/src/App.tsx
Adds <Route path="/settings" element={<Settings />} />.
client/src/components/Layout.tsx
Flips the Settings nav entry to available: true.
e2e/tests/settings-react-security.spec.js
Five smoke tests against /app/settings mirroring the coverage of
settings-faq-dictation.spec.js for the vanilla tree: field/button
presence for all three sections, password-mismatch inline error,
revoke-all click surfaces the styled modal (with an explicit
page.on('dialog') guard to catch any future regression to native
confirm()).
Note: the e2e container image currently predates the /app/* route
(its /app/public/app/ directory is absent), so running this spec requires
a rebuild — deferred to Daniel's call. Client tsc -b, server tsc --noEmit,
and vite build all pass locally.
End-to-end proof that the full React + Vite + Tailwind + TypeScript
pipeline works against the existing Express API:
client/src/pages/Extensions.tsx
Minimum-viable port of the Extensions tab. Fetches /api/extensions
via the typed api wrapper; renders an add form backed by Zod
validation (ExtensionCreateSchema). Uses @tanstack/react-query for
server state (queryKey: ['extensions'], invalidate on mutate).
Full CRUD UI (trash / restore / purge / search) is a follow-up —
this ships just enough to prove the stack works.
client/src/lib/api.ts
Thin fetch wrapper. Every React page goes through apiFetch<T>(),
which narrows ApiResponse<T> to the success shape and throws
ApiError on failure. Central spot for future request/response
instrumentation, auth-token refresh, etc.
client/src/App.tsx
Replaced the Vite starter splash screen with a minimal router:
BrowserRouter basename='/app', routes for / (landing) and
/extensions. QueryClientProvider wraps the tree so every page can
use useQuery/useMutation.
client/src/shared/
Mirrored copy of /shared/types.ts + schemas.ts. Canonical source
stays at /shared/ (used by backend). A post-migration task is to
wire proper TypeScript project references so the client can import
straight from /shared — TS 6's cross-root bundler-mode paths
resolution isn't pulling it in cleanly. For now, the mirror is
header-annotated 'do not edit, mirror only'.
client/src/index.css
Tailwind v4 @theme block declaring the shadcn design tokens as
first-class CSS custom properties, which exposes the
bg-background / text-foreground / border-border utility classes
the page components use. v4 no longer uses @apply for these —
the theme block is the idiomatic form.
server.ts
Added:
app.get('/app/*splat', ...) → sendFile public/app/index.html
so React Router deep links (e.g. /app/extensions) resolve
client-side. Express static middleware below continues to serve
the hashed /app/assets/*.js + .css.
Build output (checked into public/app/ so the next prod docker
rebuild ships the React bundle without requiring a client/npm
install step in the Dockerfile — that's a day-8 refinement):
index.html 0.46 kB gzip 0.29 kB
index.css 9.51 kB gzip 2.69 kB
index.js 329.24 kB gzip 100.98 kB
Typecheck green on both sides:
server: npx tsc --noEmit → EXIT 0
client: npx tsc -b → EXIT 0
client: npx vite build → 150 modules, 219ms
How to see it live (after Daniel rebuilds prod):
https://<host>/app/ → React landing page
https://<host>/app/extensions → React-rendered Extensions list
https://<host>/ → unchanged vanilla JS app
Nothing destructive. The vanilla JS /extensions tab still works
identically. The React /app/extensions route talks to the same
/api/extensions backend endpoints. Both render from the same
PostgreSQL rows.
This closes out the 7-day migration scaffolding. The rest is a
port-one-tab-at-a-time grind that Codex (or anyone) can pick up
tab-by-tab with the Playwright suite as the safety net.