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.
change)
Creates the wire-protocol contract every route and every client
component will import from. Renames server.js → server.ts as a pure
rename (zero bytes of logic changed) so the entry point becomes the
first file tsc type-checks against the real compilerOptions.
shared/types.ts — what's in it and why
- ApiResponse<T> envelope: (ApiOk<T> & T) | ApiErr. Every route
returns one of these; client code narrows on `r.success`.
- One typed "Ok" shape per endpoint, keyed by the actual res.json()
call in the current handler. Walked every src/routes/*.js file
and transcribed the literal keys: hpi, soap, note, hospitalCourse,
review, refined, shortened, questions, narrative+summary, etc.
No inventive renaming — wire stays identical, only the types are
new.
- Critical mismatches the types now prevent at compile time:
/api/refine returns `refined` (not `content` — a past bug)
/api/sick-visit/note (not /api/generate-sick-visit — past bug)
/api/generate-hospital-course returns `hospitalCourse` (not
`narrative` — past bug)
All three were caught and fixed earlier in Playwright; with the
shared types they become compile errors for any future regression.
server.ts
- Pure rename via `git mv`. Body unchanged.
- Compiled dist/server.js diffs against the original server.js by
two lines (TypeScript prepends `"use strict"` and the CommonJS
export marker). No semantic drift.
tsconfig.json tweak
- Include list adds server.ts alongside server.js so tsc doesn't
silently skip the entry point during the intermediate state
where `.js` entries might reappear.
- baseUrl removed (deprecated in TS 6); paths now uses './shared/*'.
package.json
- main: dist/server.js (post-compile entry)
- start: node dist/server.js
- prebuild: rm -rf dist (clean emit every time)
- dev: ts-node-dev for fast TS-aware reloads
The Dockerfile is still unchanged. The deployed prod and e2e
containers still run their baked-in server.js from the previous
image — this migration day has no effect on either until the final
rebuild at the end of day 7.
Next: day 3 renames the 29 route files one-by-one, each adding the
ApiResponse<T> type parameter to its res.json() calls.