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.
This commit is contained in:
Daniel 2026-04-24 02:45:19 +02:00
parent c7a4e609e8
commit c157c709c8
33 changed files with 695 additions and 131 deletions

View file

@ -2,16 +2,15 @@ import { lazy, Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import Layout from '@/components/Layout';
import AuthGuard from '@/components/AuthGuard';
// Lightweight pages stay in the main chunk.
import Extensions from '@/pages/Extensions';
import Faq from '@/pages/Faq';
// Heavy pages lazy-load — keeps the initial bundle small so the home
// screen and quick tools (Extensions, FAQ) paint fast, and users only
// download the big clinical-reference tables (PE_DATA, Fenton LMS,
// Rosner BP splines, Bedside drug panels, Admin sub-tabs) when they
// open those specific routes.
// Heavy pages lazy-load — keeps the initial bundle small.
const Auth = lazy(() => import('@/pages/Auth'));
const ResetPassword = lazy(() => import('@/pages/ResetPassword'));
const Dictation = lazy(() => import('@/pages/Dictation'));
const Encounter = lazy(() => import('@/pages/Encounter'));
const Soap = lazy(() => import('@/pages/Soap'));
@ -41,19 +40,18 @@ function RouteFallback() {
function Home() {
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<h1 className="text-2xl font-semibold">Pediatric AI Scribe React client</h1>
<p className="text-sm text-muted-foreground">
This is the new React tree. The legacy vanilla-JS app still lives at{' '}
<a href="/" className="underline">/</a>.
</p>
<p className="text-sm text-muted-foreground">Quick links:</p>
<h1 className="text-2xl font-semibold">Pediatric AI Scribe</h1>
<p className="text-sm text-muted-foreground">Pick a tool from the sidebar.</p>
<ul className="list-disc pl-6 text-sm space-y-1">
<li><Link to="/encounter" className="underline">Encounter HPI</Link></li>
<li><Link to="/dictation" className="underline">Dictation HPI</Link></li>
<li><Link to="/soap" className="underline">SOAP Note</Link></li>
<li><Link to="/sickvisit" className="underline">Sick Visit</Link></li>
<li><Link to="/extensions" className="underline">Extensions &amp; Pagers</Link></li>
<li><Link to="/faq" className="underline">FAQ</Link></li>
<li><Link to="/wellvisit" className="underline">Well Visit</Link></li>
<li><Link to="/peguide" className="underline">Physical Exam Guide</Link></li>
<li><Link to="/bedside" className="underline">Bedside</Link></li>
<li><Link to="/calculators" className="underline">Calculators</Link></li>
<li><Link to="/learning" className="underline">Learning Hub</Link></li>
</ul>
</div>
);
@ -62,31 +60,39 @@ function Home() {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/app">
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/encounter" element={<Suspense fallback={<RouteFallback />}><Encounter /></Suspense>} />
<Route path="/dictation" element={<Suspense fallback={<RouteFallback />}><Dictation /></Suspense>} />
<Route path="/soap" element={<Suspense fallback={<RouteFallback />}><Soap /></Suspense>} />
<Route path="/sickvisit" element={<Suspense fallback={<RouteFallback />}><SickVisit /></Suspense>} />
<Route path="/hospital" element={<Suspense fallback={<RouteFallback />}><HospitalCourse /></Suspense>} />
<Route path="/chart" element={<Suspense fallback={<RouteFallback />}><ChartReview /></Suspense>} />
<Route path="/wellvisit" element={<Suspense fallback={<RouteFallback />}><WellVisit /></Suspense>} />
<Route path="/vaxschedule" element={<Suspense fallback={<RouteFallback />}><VaxSchedule /></Suspense>} />
<Route path="/catchup" element={<Suspense fallback={<RouteFallback />}><Catchup /></Suspense>} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/settings" element={<Suspense fallback={<RouteFallback />}><Settings /></Suspense>} />
<Route path="/learning" element={<Suspense fallback={<RouteFallback />}><Learning /></Suspense>} />
<Route path="/peguide" element={<Suspense fallback={<RouteFallback />}><PeGuide /></Suspense>} />
<Route path="/bedside" element={<Suspense fallback={<RouteFallback />}><Bedside /></Suspense>} />
<Route path="/calculators" element={<Suspense fallback={<RouteFallback />}><Calculators /></Suspense>} />
<Route path="/admin" element={<Suspense fallback={<RouteFallback />}><Admin /></Suspense>} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
<BrowserRouter>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Public routes */}
<Route path="/auth" element={<Auth />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Private routes — AuthGuard + Layout wrap everything */}
<Route element={<AuthGuard />}>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/encounter" element={<Encounter />} />
<Route path="/dictation" element={<Dictation />} />
<Route path="/soap" element={<Soap />} />
<Route path="/sickvisit" element={<SickVisit />} />
<Route path="/hospital" element={<HospitalCourse />} />
<Route path="/chart" element={<ChartReview />} />
<Route path="/wellvisit" element={<WellVisit />} />
<Route path="/vaxschedule" element={<VaxSchedule />} />
<Route path="/catchup" element={<Catchup />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/learning" element={<Learning />} />
<Route path="/peguide" element={<PeGuide />} />
<Route path="/bedside" element={<Bedside />} />
<Route path="/calculators" element={<Calculators />} />
<Route path="/admin" element={<Admin />} />
<Route path="/faq" element={<Faq />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</QueryClientProvider>
);

View file

@ -0,0 +1,30 @@
// ============================================================
// AUTH GUARD — redirects to /auth if /api/auth/me returns 401.
// Wraps every private route so unauthenticated users land on
// the login screen automatically.
// ============================================================
import { Navigate, useLocation, Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { MeOk } from '@/shared/types';
export default function AuthGuard() {
const loc = useLocation();
const { data, isLoading, isError } = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
retry: false,
staleTime: 5 * 60_000,
});
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center text-sm text-muted-foreground">Loading</div>;
}
if (isError || !data?.user) {
// Preserve deep link so we can bounce the user back after sign-in.
const next = encodeURIComponent(loc.pathname + loc.search);
return <Navigate to={`/auth?next=${next}`} replace />;
}
return <Outlet />;
}

View file

@ -108,10 +108,21 @@ export default function Layout({ children }: { children?: ReactNode }) {
<div className="min-h-screen bg-background text-foreground flex">
{/* Sidebar */}
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
<div className="px-2 py-1 border-b border-border pb-3">
<div className="px-2 py-1 border-b border-border pb-3 flex items-center justify-between gap-2">
<div className="font-semibold">Pediatric AI Scribe</div>
<a href="/" className="text-[11px] text-muted-foreground underline">
back to legacy app
<a
href="/api/auth/logout"
onClick={async (e) => {
e.preventDefault();
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch { /* ignore */ }
window.location.href = '/auth';
}}
className="text-[11px] text-muted-foreground hover:text-foreground"
title="Sign out"
>
Sign out
</a>
</div>
{NAV.map((group) => {

View file

@ -0,0 +1,83 @@
// ============================================================
// Turnstile widget wrapper. Loads the cloudflare challenges script
// once, renders a widget when mounted, exposes the resolved token
// via onToken. If `siteKey` is null/empty (e.g. e2e container with
// TURNSTILE_SITE_KEY=""), renders nothing and auto-reports an empty
// token so the surrounding form can submit unchanged — this mirrors
// the vanilla behaviour where an unset site key is a no-op.
// ============================================================
import { useEffect, useRef } from 'react';
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
onTurnstileLoad?: () => void;
}
}
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
let loadedOrLoading = false;
const readyCallbacks: Array<() => void> = [];
function ensureScript(): Promise<void> {
return new Promise((resolve) => {
if (typeof window === 'undefined') { resolve(); return; }
if (window.turnstile) { resolve(); return; }
readyCallbacks.push(resolve);
if (loadedOrLoading) return;
loadedOrLoading = true;
window.onTurnstileLoad = () => {
for (const cb of readyCallbacks.splice(0)) cb();
};
const s = document.createElement('script');
s.src = SCRIPT_SRC;
s.async = true;
s.defer = true;
document.head.appendChild(s);
});
}
interface Props {
siteKey: string | null | undefined;
onToken: (token: string) => void;
action?: string;
theme?: 'light' | 'dark' | 'auto';
}
export default function Turnstile({ siteKey, onToken, action, theme = 'light' }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
if (!siteKey) { onToken(''); return; }
let cancelled = false;
ensureScript().then(() => {
if (cancelled || !containerRef.current || !window.turnstile) return;
try {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
action,
callback: (token: string) => onToken(token),
'error-callback': () => onToken(''),
'expired-callback': () => onToken(''),
});
} catch { /* ignore render errors — e.g. repeated mount */ }
});
return () => {
cancelled = true;
if (widgetIdRef.current && window.turnstile) {
try { window.turnstile.remove(widgetIdRef.current); } catch { /* ignore */ }
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [siteKey]);
if (!siteKey) return null;
return <div ref={containerRef} className="cf-turnstile my-2" />;
}

310
client/src/pages/Auth.tsx Normal file
View file

@ -0,0 +1,310 @@
// ============================================================
// AUTH SCREEN — login / register / forgot-password. Mirrors the
// vanilla auth screen feature-for-feature: Turnstile, optional
// 2FA TOTP field (reveals on requires2FA response), SSO button
// (when OIDC enabled), resend-verification link, HIPAA notice,
// APK download link.
// ============================================================
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import Turnstile from '@/components/Turnstile';
import type { PublicConfigOk } from '@/shared/types';
type Mode = 'login' | 'register' | 'forgot';
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
const btnSso = 'w-full rounded-md bg-slate-900 text-white px-4 py-3 text-sm font-semibold disabled:opacity-60 hover:bg-slate-800';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
const linkBtn = 'text-sm text-primary hover:underline';
const msgOk = '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';
const msgErr = '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';
const msgInfo = '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 useRedirectAfterLogin() {
const nav = useNavigate();
const loc = useLocation();
return () => {
const next = new URLSearchParams(loc.search).get('next') || '/';
nav(next, { replace: true });
};
}
export default function Auth() {
const [mode, setMode] = useState<Mode>('login');
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [info, setInfo] = useState('');
const qc = useQueryClient();
const redirect = useRedirectAfterLogin();
const { data: cfg } = useQuery<PublicConfigOk>({
queryKey: ['public-config'],
queryFn: () => api.get<PublicConfigOk>('/api/auth/public-config'),
staleTime: 60_000,
});
// If local auth is disabled (SSO-only), hide login/register and force SSO.
const localDisabled = !!cfg?.disableLocalAuth;
function switchMode(next: Mode) {
setMode(next); setErr(''); setOk(''); setInfo('');
}
return (
<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">
<div className={card}>
<header className="text-center space-y-1">
<div className="text-4xl">🩺</div>
<h1 className="text-xl font-bold">Pediatric AI Scribe</h1>
<p className="text-xs text-muted-foreground">AI-Powered Clinical Documentation</p>
</header>
{err && <div className={msgErr}>{err}</div>}
{info && <div className={msgInfo}>{info}</div>}
{ok && <div className={msgOk}>{ok}</div>}
{mode === 'login' && !localDisabled && (
<LoginForm
cfg={cfg}
onErr={setErr} onInfo={setInfo} onOk={setOk}
onLogin={() => { qc.invalidateQueries({ queryKey: ['auth-me'] }); redirect(); }}
switchMode={switchMode}
/>
)}
{mode === 'register' && !localDisabled && (cfg?.registrationEnabled ?? true) && (
<RegisterForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
)}
{mode === 'forgot' && !localDisabled && (
<ForgotForm cfg={cfg} onErr={setErr} onOk={setOk} switchMode={switchMode} />
)}
{/* SSO — always visible when OIDC enabled, even in login-only mode. */}
{cfg?.oidcEnabled && (
<>
{!localDisabled && <div className="relative my-4"><div className="absolute inset-0 flex items-center"><span className="w-full border-t border-border" /></div><div className="relative flex justify-center text-xs text-muted-foreground"><span className="bg-card px-3">or</span></div></div>}
<a href="/api/auth/oidc" className={btnSso + ' text-center block no-underline'} data-testid="auth-oidc">
🛡 {cfg.ssoButtonLabel || 'Sign in with SSO'}
</a>
</>
)}
{localDisabled && !cfg?.oidcEnabled && (
<div className={msgInfo}>Single sign-on not configured. Contact your administrator.</div>
)}
<div className="pt-4 border-t border-border text-xs text-muted-foreground space-y-2">
<div className="flex gap-2 items-start">
<span></span>
<p>HIPAA-compliant AI providers available with BAA. Check your institution's guidelines. Not intended for clinical use without proper authorization.</p>
</div>
<div className="text-center">
<a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest"
target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
📱 Download Android app (APK)
</a>
</div>
</div>
</div>
</div>
);
}
// ── Login ──────────────────────────────────────────────────
function LoginForm({
cfg, onErr, onInfo, onOk, onLogin, switchMode,
}: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onInfo: (s: string) => void;
onOk: (s: string) => void;
onLogin: () => void;
switchMode: (m: Mode) => void;
}) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totp, setTotp] = useState('');
const [needs2fa, setNeeds2fa] = useState(false);
const [needsVerify, setNeedsVerify] = useState(false);
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
const [resending, setResending] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onInfo(''); onOk('');
if (!email || !password) { onErr('Enter email and password'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { email, password };
if (turnstileToken) body.turnstileToken = turnstileToken;
if (totp) body.totpCode = totp;
const resp = await api.post<{ token?: string; requires2FA?: boolean; needsVerification?: boolean; message?: string }>(
'/api/auth/login', body,
);
if (resp.requires2FA) {
setNeeds2fa(true);
onInfo('Enter your 2FA code');
setBusy(false);
return;
}
if (resp.needsVerification) {
setNeedsVerify(true);
onErr('Verify your email first. Check your inbox.');
setBusy(false);
return;
}
if (resp.token) {
onOk('Signed in');
onLogin();
return;
}
onErr('Login failed');
} catch (e) {
onErr((e as ApiError).message || 'Login failed');
} finally {
setBusy(false);
}
}
async function resend() {
if (!email) { onErr('Enter your email first'); return; }
setResending(true);
try {
const r = await api.post<{ message?: string }>('/api/auth/resend-verification', { email });
onOk(r.message || 'Verification email sent');
} catch (e) {
onErr((e as ApiError).message || 'Failed to resend');
} finally { setResending(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-login-form">
<h2 className="text-lg font-semibold text-center">Sign in</h2>
<div><label className={label}>Email</label><input type="email" autoFocus required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-email" /></div>
<div><label className={label}>Password</label><input type="password" required className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-password" /></div>
{needs2fa && (
<div><label className={label}>2FA code</label><input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} className={input + ' font-mono tracking-widest'} value={totp} onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))} data-testid="auth-totp" autoFocus /></div>
)}
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-submit">
{busy ? 'Signing in…' : needs2fa ? 'Verify 2FA' : 'Sign in'}
</button>
{needsVerify && (
<div className="text-center">
<button type="button" onClick={resend} className={linkBtn} disabled={resending} data-testid="auth-resend-verify">
{resending ? 'Sending…' : 'Resend verification link'}
</button>
</div>
)}
<div className="flex justify-between text-sm">
{(cfg?.registrationEnabled ?? true) && (
<button type="button" onClick={() => switchMode('register')} className={linkBtn} data-testid="auth-show-register">Create account</button>
)}
<button type="button" onClick={() => switchMode('forgot')} className={linkBtn + ' ml-auto'} data-testid="auth-show-forgot">Forgot password?</button>
</div>
</form>
);
}
// ── Register ───────────────────────────────────────────────
function RegisterForm({ cfg, onErr, onOk, switchMode }: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onOk: (s: string) => void;
switchMode: (m: Mode) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onOk('');
if (!name || !email || password.length < 8) { onErr('Fill all fields (password 8+ chars)'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { name, email, password };
if (turnstileToken) body.turnstileToken = turnstileToken;
const r = await api.post<{ message?: string; needsVerification?: boolean; token?: string }>('/api/auth/register', body);
if (r.needsVerification) {
onOk('Account created. Check your email to verify.');
} else if (r.token) {
onOk('Account created. Signing you in…');
setTimeout(() => { window.location.href = '/'; }, 800);
} else {
onOk(r.message || 'Account created');
}
switchMode('login');
} catch (e) {
onErr((e as ApiError).message || 'Registration failed');
} finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-register-form">
<h2 className="text-lg font-semibold text-center">Create account</h2>
<div><label className={label}>Full name</label><input type="text" required autoFocus className={input} value={name} onChange={(e) => setName(e.target.value)} data-testid="auth-reg-name" /></div>
<div><label className={label}>Email</label><input type="email" required className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-reg-email" /></div>
<div><label className={label}>Password (8+ characters)</label><input type="password" required minLength={8} className={input} value={password} onChange={(e) => setPassword(e.target.value)} data-testid="auth-reg-password" /></div>
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-reg-submit">
{busy ? 'Creating…' : 'Create account'}
</button>
<div className="text-center">
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
</div>
</form>
);
}
// ── Forgot ─────────────────────────────────────────────────
function ForgotForm({ cfg, onErr, onOk, switchMode }: {
cfg: PublicConfigOk | undefined;
onErr: (s: string) => void;
onOk: (s: string) => void;
switchMode: (m: Mode) => void;
}) {
const [email, setEmail] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
onErr(''); onOk('');
if (!email) { onErr('Enter your email'); return; }
if (cfg?.turnstileSiteKey && !turnstileToken) { onErr('Please complete the verification'); return; }
setBusy(true);
try {
const body: Record<string, string> = { email };
if (turnstileToken) body.turnstileToken = turnstileToken;
const r = await api.post<{ message?: string }>('/api/auth/forgot-password', body);
onOk(r.message || 'If an account exists, a reset link was sent.');
} catch (e) {
onErr((e as ApiError).message || 'Request failed');
} finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="space-y-3" data-testid="auth-forgot-form">
<h2 className="text-lg font-semibold text-center">Reset password</h2>
<p className="text-xs text-muted-foreground text-center">Enter your email and we'll send a reset link.</p>
<div><label className={label}>Email</label><input type="email" required autoFocus className={input} value={email} onChange={(e) => setEmail(e.target.value)} data-testid="auth-forgot-email" /></div>
<Turnstile siteKey={cfg?.turnstileSiteKey} onToken={setTurnstileToken} />
<button type="submit" className={btnPrimary} disabled={busy} data-testid="auth-forgot-submit">
{busy ? 'Sending…' : 'Send reset link'}
</button>
<div className="text-center">
<button type="button" onClick={() => switchMode('login')} className={linkBtn}>Back to sign in</button>
</div>
</form>
);
}

View file

@ -0,0 +1,67 @@
// ============================================================
// RESET PASSWORD — landing for the email link (?token=xxx).
// POSTs /api/auth/reset-password with {token, newPassword}.
// ============================================================
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api, ApiError } from '@/lib/api';
const card = 'rounded-2xl border border-border bg-card p-6 shadow-lg space-y-4 w-full max-w-md';
const btnPrimary = 'w-full rounded-md bg-primary text-primary-foreground px-4 py-3 text-sm font-semibold disabled:opacity-60';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-ring';
const label = 'block text-xs font-semibold text-muted-foreground mb-1';
const msgOk = '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';
const msgErr = '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';
const msgInfo = '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';
export default function ResetPassword() {
const [params] = useSearchParams();
const nav = useNavigate();
const token = params.get('token') || '';
const [newPassword, setNewPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [warn, setWarn] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(''); setOk(''); setWarn('');
if (!token) { setErr('Missing reset token. Open the reset link from your email.'); return; }
if (newPassword.length < 8) { setErr('Password must be 8+ characters'); return; }
if (newPassword !== confirm) { setErr('Passwords do not match'); return; }
setBusy(true);
try {
const r = await api.post<{ passwordWarning?: string }>('/api/auth/reset-password', { token, newPassword });
setOk('Password reset. You can now sign in.');
if (r.passwordWarning) setWarn(r.passwordWarning);
setTimeout(() => nav('/auth', { replace: true }), 2500);
} catch (e) {
setErr((e as ApiError).message || 'Reset failed. The link may have expired.');
} finally { setBusy(false); }
}
return (
<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">
<div className={card}>
<header className="text-center space-y-1">
<div className="text-4xl">🔑</div>
<h1 className="text-xl font-bold">Set a new password</h1>
<p className="text-xs text-muted-foreground">Choose a password at least 8 characters long.</p>
</header>
{err && <div className={msgErr}>{err}</div>}
{ok && <div className={msgOk}>{ok}</div>}
{warn && <div className={msgInfo}>{warn}</div>}
<form onSubmit={submit} className="space-y-3" data-testid="reset-password-form">
<div><label className={label}>New password</label><input type="password" required minLength={8} className={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoFocus data-testid="reset-new" /></div>
<div><label className={label}>Confirm new password</label><input type="password" required minLength={8} className={input} value={confirm} onChange={(e) => setConfirm(e.target.value)} data-testid="reset-confirm" /></div>
<button type="submit" className={btnPrimary} disabled={busy || !token} data-testid="reset-submit">
{busy ? 'Resetting…' : 'Reset password'}
</button>
</form>
</div>
</div>
);
}

View file

@ -505,6 +505,16 @@ export interface LearningSlidesOk {
slides: string[]; // pre-rendered HTML per slide from the server
}
// Public config for the auth screen (anonymous users allowed).
// /api/auth/public-config
export interface PublicConfigOk {
registrationEnabled: boolean;
turnstileSiteKey: string | null;
oidcEnabled: boolean;
disableLocalAuth: boolean;
ssoButtonLabel: string;
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {
id: number;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{t as e}from"./jsx-runtime-ByY1xr43.js";import{i as t,o as n}from"./index-DTb5Y4u0.js";var r=e();function i(){let{data:e,isLoading:i,error:a}=n({queryKey:[`schedule-data`],queryFn:()=>t.get(`/api/schedule-data`)});return i?(0,r.jsx)(`div`,{className:`p-6 text-sm text-muted-foreground`,children:`Loading…`}):a?(0,r.jsx)(`div`,{className:`p-6 text-sm text-destructive`,children:a.message}):e?(0,r.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,r.jsxs)(`header`,{children:[(0,r.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Catch-Up Schedule`}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`CDC 2025 catch-up immunization schedule — minimum ages and intervals per vaccine.`})]}),Object.entries(e.catchUpSchedule).map(([t,n])=>{let i=e.vaccineFullNames[t]||t,a=n.catchUpNotes?Array.isArray(n.catchUpNotes)?n.catchUpNotes:[n.catchUpNotes]:[];return(0,r.jsxs)(`section`,{className:`rounded-lg border border-border bg-card overflow-hidden`,children:[(0,r.jsxs)(`header`,{className:`px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between`,children:[(0,r.jsx)(`h2`,{className:`text-sm font-semibold`,children:i}),n.minimumAgeForDose1&&(0,r.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`Min age dose 1: `,(0,r.jsx)(`strong`,{children:n.minimumAgeForDose1})]})]}),n.series&&n.series.length>0&&(0,r.jsxs)(`table`,{className:`w-full text-xs`,children:[(0,r.jsx)(`thead`,{className:`bg-muted/20`,children:(0,r.jsxs)(`tr`,{children:[(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Dose`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min age`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min interval from prev`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Notes`})]})}),(0,r.jsx)(`tbody`,{children:n.series.map(e=>(0,r.jsxs)(`tr`,{className:`border-t border-border`,children:[(0,r.jsxs)(`td`,{className:`px-3 py-2 font-semibold`,children:[`Dose `,e.dose]}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumAge||``}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumIntervalToPrev||``}),(0,r.jsx)(`td`,{className:`px-3 py-2 text-muted-foreground`,children:e.notes||``})]},String(e.dose)))})]}),a.length>0&&(0,r.jsx)(`ul`,{className:`list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1`,children:a.map((e,t)=>(0,r.jsx)(`li`,{children:e},t))})]},t)})]}):null}export{i as default};
import{t as e}from"./jsx-runtime-ByY1xr43.js";import{i as t,l as n}from"./index-BuU36CvS.js";var r=e();function i(){let{data:e,isLoading:i,error:a}=n({queryKey:[`schedule-data`],queryFn:()=>t.get(`/api/schedule-data`)});return i?(0,r.jsx)(`div`,{className:`p-6 text-sm text-muted-foreground`,children:`Loading…`}):a?(0,r.jsx)(`div`,{className:`p-6 text-sm text-destructive`,children:a.message}):e?(0,r.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,r.jsxs)(`header`,{children:[(0,r.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Catch-Up Schedule`}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`CDC 2025 catch-up immunization schedule — minimum ages and intervals per vaccine.`})]}),Object.entries(e.catchUpSchedule).map(([t,n])=>{let i=e.vaccineFullNames[t]||t,a=n.catchUpNotes?Array.isArray(n.catchUpNotes)?n.catchUpNotes:[n.catchUpNotes]:[];return(0,r.jsxs)(`section`,{className:`rounded-lg border border-border bg-card overflow-hidden`,children:[(0,r.jsxs)(`header`,{className:`px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between`,children:[(0,r.jsx)(`h2`,{className:`text-sm font-semibold`,children:i}),n.minimumAgeForDose1&&(0,r.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`Min age dose 1: `,(0,r.jsx)(`strong`,{children:n.minimumAgeForDose1})]})]}),n.series&&n.series.length>0&&(0,r.jsxs)(`table`,{className:`w-full text-xs`,children:[(0,r.jsx)(`thead`,{className:`bg-muted/20`,children:(0,r.jsxs)(`tr`,{children:[(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Dose`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min age`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min interval from prev`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Notes`})]})}),(0,r.jsx)(`tbody`,{children:n.series.map(e=>(0,r.jsxs)(`tr`,{className:`border-t border-border`,children:[(0,r.jsxs)(`td`,{className:`px-3 py-2 font-semibold`,children:[`Dose `,e.dose]}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumAge||``}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumIntervalToPrev||``}),(0,r.jsx)(`td`,{className:`px-3 py-2 text-muted-foreground`,children:e.notes||``})]},String(e.dose)))})]}),a.length>0&&(0,r.jsx)(`ul`,{className:`list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1`,children:a.map((e,t)=>(0,r.jsx)(`li`,{children:e},t))})]},t)})]}):null}export{i as default};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,t as a}from"./index-DTb5Y4u0.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`outpatient`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/generate-hpi-dictation`,e),onSuccess:e=>m(e.hpi),onError:()=>m(null)});function v(t){t.preventDefault(),g(null);let r={transcript:d,patientAge:e,patientGender:n,setting:l},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}function y(){f(``),m(null),g(null)}let b=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Voice Dictation → HPI`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Dictate your narrative → AI restructures into polished HPI. Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:b,placeholder:`e.g. 8 months`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:b,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Setting`}),(0,s.jsxs)(`select`,{className:b,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`outpatient`,children:`Outpatient`}),(0,s.jsx)(`option`,{value:`inpatient`,children:`Inpatient / Floors`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript / dictation`}),(0,s.jsx)(`button`,{type:`button`,onClick:y,className:`text-xs text-muted-foreground underline`,children:`Clear`})]}),(0,s.jsx)(`textarea`,{className:b+` min-h-[200px] font-mono text-sm`,placeholder:`Type or paste your dictation here, then click Generate.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`div`,{className:`flex gap-2`,children:(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate HPI`})})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated HPI`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,t as a}from"./index-BuU36CvS.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`outpatient`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/generate-hpi-dictation`,e),onSuccess:e=>m(e.hpi),onError:()=>m(null)});function v(t){t.preventDefault(),g(null);let r={transcript:d,patientAge:e,patientGender:n,setting:l},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}function y(){f(``),m(null),g(null)}let b=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Voice Dictation → HPI`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Dictate your narrative → AI restructures into polished HPI. Audio-capture UI is a follow-up; this minimal form supports typed/pasted transcripts.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:b,placeholder:`e.g. 8 months`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:b,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Setting`}),(0,s.jsxs)(`select`,{className:b,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`outpatient`,children:`Outpatient`}),(0,s.jsx)(`option`,{value:`inpatient`,children:`Inpatient / Floors`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript / dictation`}),(0,s.jsx)(`button`,{type:`button`,onClick:y,className:`text-xs text-muted-foreground underline`,children:`Clear`})]}),(0,s.jsx)(`textarea`,{className:b+` min-h-[200px] font-mono text-sm`,placeholder:`Type or paste your dictation here, then click Generate.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`div`,{className:`flex gap-2`,children:(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate HPI`})})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated HPI`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};

View file

@ -1 +1 @@
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,t as a}from"./index-DTb5Y4u0.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`outpatient`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/generate-hpi-encounter`,e),onSuccess:e=>m(e.hpi)});function v(t){t.preventDefault(),g(null);let r={transcript:d,patientAge:e,patientGender:n,setting:l},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}let y=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Live Encounter → HPI`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Record or paste an encounter transcript; generate a structured HPI.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. 5 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:y,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Setting`}),(0,s.jsxs)(`select`,{className:y,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`outpatient`,children:`Outpatient`}),(0,s.jsx)(`option`,{value:`inpatient`,children:`Inpatient / Floors`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript`}),(0,s.jsx)(`textarea`,{className:y+` min-h-[220px] font-mono text-sm`,placeholder:`Type or paste an encounter transcript, then click Generate.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate HPI`})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated HPI`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,t as a}from"./index-BuU36CvS.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`outpatient`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/generate-hpi-encounter`,e),onSuccess:e=>m(e.hpi)});function v(t){t.preventDefault(),g(null);let r={transcript:d,patientAge:e,patientGender:n,setting:l},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}let y=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Live Encounter → HPI`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Record or paste an encounter transcript; generate a structured HPI.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. 5 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:y,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Setting`}),(0,s.jsxs)(`select`,{className:y,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`outpatient`,children:`Outpatient`}),(0,s.jsx)(`option`,{value:`inpatient`,children:`Inpatient / Floors`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript`}),(0,s.jsx)(`textarea`,{className:y+` min-h-[220px] font-mono text-sm`,placeholder:`Type or paste an encounter transcript, then click Generate.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate HPI`})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated HPI`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
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};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,n as a}from"./index-DTb5Y4u0.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(``),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/sick-visit/note`,e),onSuccess:e=>m(e.note)});function v(t){t.preventDefault(),g(null);let r={patientAge:e,patientGender:n,chiefComplaint:l,transcript:d},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}let y=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Sick Visit`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Chief complaint + transcript → structured sick-visit note.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. 4 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:y,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1 col-span-3 md:col-span-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Chief complaint`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. Fever x 2 days`,value:l,onChange:e=>u(e.target.value)})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript / dictation`}),(0,s.jsx)(`textarea`,{className:y+` min-h-[200px] font-mono text-sm`,placeholder:`Encounter narrative — transcribed or dictated.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!l.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate Note`})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Sick Visit Note`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,n as a}from"./index-BuU36CvS.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(``),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(null),[h,g]=(0,o.useState)(null),_=r({mutationFn:e=>i.post(`/api/sick-visit/note`,e),onSuccess:e=>m(e.note)});function v(t){t.preventDefault(),g(null);let r={patientAge:e,patientGender:n,chiefComplaint:l,transcript:d},i=a.safeParse(r);if(!i.success){g(i.error.issues.map(e=>e.message).join(`, `));return}m(null),_.mutate(i.data)}let y=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Sick Visit`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Chief complaint + transcript → structured sick-visit note.`})]}),(0,s.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. 4 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:y,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1 col-span-3 md:col-span-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Chief complaint`}),(0,s.jsx)(`input`,{className:y,placeholder:`e.g. Fever x 2 days`,value:l,onChange:e=>u(e.target.value)})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript / dictation`}),(0,s.jsx)(`textarea`,{className:y+` min-h-[200px] font-mono text-sm`,placeholder:`Encounter narrative — transcribed or dictated.`,value:d,onChange:e=>f(e.target.value)})]}),h&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:h}),_.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:_.isPending||!l.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:_.isPending?`Generating…`:`Generate Note`})]}),p&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Sick Visit Note`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(p),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:p})]})]})}export{c as default};

View file

@ -1 +1 @@
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,r as a}from"./index-DTb5Y4u0.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`full`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(``),[h,g]=(0,o.useState)(null),[_,v]=(0,o.useState)(null),y=r({mutationFn:e=>i.post(`/api/generate-soap`,e),onSuccess:e=>g(e.soap)});function b(t){t.preventDefault(),v(null);let r={transcript:d,patientAge:e,patientGender:n,type:l,additionalInstructions:p},i=a.safeParse(r);if(!i.success){v(i.error.issues.map(e=>e.message).join(`, `));return}g(null),y.mutate(i.data)}let x=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`SOAP Note`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Encounter transcript → full SOAP or subjective-only narrative.`})]}),(0,s.jsxs)(`form`,{onSubmit:b,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:x,placeholder:`e.g. 3 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:x,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Output type`}),(0,s.jsxs)(`select`,{className:x,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`full`,children:`Full SOAP`}),(0,s.jsx)(`option`,{value:`subjective`,children:`Subjective only`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript`}),(0,s.jsx)(`textarea`,{className:x+` min-h-[200px] font-mono text-sm`,placeholder:`Type or paste the encounter transcript.`,value:d,onChange:e=>f(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsxs)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:[`Additional instructions `,(0,s.jsx)(`span`,{className:`text-muted-foreground normal-case font-normal`,children:`(optional)`})]}),(0,s.jsx)(`textarea`,{className:x+` min-h-[60px] text-sm`,placeholder:`e.g., 'Include return precautions', 'Add differential for otitis media'`,value:p,onChange:e=>m(e.target.value)})]}),_&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_}),y.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:y.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:y.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:y.isPending?`Generating…`:`Generate SOAP`})]}),h&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated SOAP`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(h),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:h})]})]})}export{c as default};
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,r as a}from"./index-BuU36CvS.js";var o=e(t(),1),s=n();function c(){let[e,t]=(0,o.useState)(``),[n,c]=(0,o.useState)(``),[l,u]=(0,o.useState)(`full`),[d,f]=(0,o.useState)(``),[p,m]=(0,o.useState)(``),[h,g]=(0,o.useState)(null),[_,v]=(0,o.useState)(null),y=r({mutationFn:e=>i.post(`/api/generate-soap`,e),onSuccess:e=>g(e.soap)});function b(t){t.preventDefault(),v(null);let r={transcript:d,patientAge:e,patientGender:n,type:l,additionalInstructions:p},i=a.safeParse(r);if(!i.success){v(i.error.issues.map(e=>e.message).join(`, `));return}g(null),y.mutate(i.data)}let x=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,s.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,s.jsxs)(`header`,{children:[(0,s.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`SOAP Note`}),(0,s.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Encounter transcript → full SOAP or subjective-only narrative.`})]}),(0,s.jsxs)(`form`,{onSubmit:b,className:`space-y-4`,children:[(0,s.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,s.jsx)(`input`,{className:x,placeholder:`e.g. 3 years`,value:e,onChange:e=>t(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,s.jsxs)(`select`,{className:x,value:n,onChange:e=>c(e.target.value),children:[(0,s.jsx)(`option`,{value:``,children:`Select`}),(0,s.jsx)(`option`,{children:`Male`}),(0,s.jsx)(`option`,{children:`Female`})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Output type`}),(0,s.jsxs)(`select`,{className:x,value:l,onChange:e=>u(e.target.value),children:[(0,s.jsx)(`option`,{value:`full`,children:`Full SOAP`}),(0,s.jsx)(`option`,{value:`subjective`,children:`Subjective only`})]})]})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Transcript`}),(0,s.jsx)(`textarea`,{className:x+` min-h-[200px] font-mono text-sm`,placeholder:`Type or paste the encounter transcript.`,value:d,onChange:e=>f(e.target.value)})]}),(0,s.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,s.jsxs)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:[`Additional instructions `,(0,s.jsx)(`span`,{className:`text-muted-foreground normal-case font-normal`,children:`(optional)`})]}),(0,s.jsx)(`textarea`,{className:x+` min-h-[60px] text-sm`,placeholder:`e.g., 'Include return precautions', 'Add differential for otitis media'`,value:p,onChange:e=>m(e.target.value)})]}),_&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:_}),y.error&&(0,s.jsx)(`div`,{className:`text-sm text-destructive`,children:y.error.message}),(0,s.jsx)(`button`,{type:`submit`,disabled:y.isPending||!d.trim(),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:y.isPending?`Generating…`:`Generate SOAP`})]}),h&&(0,s.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,s.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,s.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Generated SOAP`}),(0,s.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(h),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,s.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:h})]})]})}export{c as default};

View file

@ -1 +1 @@
import{t as e}from"./jsx-runtime-ByY1xr43.js";import{i as t,o as n}from"./index-DTb5Y4u0.js";var r=e();function i(){let{data:e,isLoading:i,error:a}=n({queryKey:[`schedule-data`],queryFn:()=>t.get(`/api/schedule-data`)});if(i)return(0,r.jsx)(`div`,{className:`p-6 text-sm text-muted-foreground`,children:`Loading schedule…`});if(a)return(0,r.jsx)(`div`,{className:`p-6 text-sm text-destructive`,children:a.message});if(!e)return null;let o=e.visitAges.filter(t=>e.periodicity[t.id]?.vaccines?.length),s=[],c=new Set;return o.forEach(t=>{e.periodicity[t.id].vaccines.forEach(e=>{c.has(e.vaccine)||(c.add(e.vaccine),s.push(e.vaccine))})}),(0,r.jsxs)(`div`,{className:`max-w-full mx-auto p-6 space-y-4`,children:[(0,r.jsxs)(`header`,{children:[(0,r.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Vaccine Schedule`}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`AAP/ACIP 2025 complete immunization schedule (018 years).`})]}),(0,r.jsx)(`div`,{className:`rounded-lg border border-border overflow-auto bg-card`,children:(0,r.jsxs)(`table`,{className:`text-xs`,children:[(0,r.jsx)(`thead`,{className:`sticky top-0 bg-muted`,children:(0,r.jsxs)(`tr`,{children:[(0,r.jsx)(`th`,{className:`text-left font-semibold px-3 py-2 border-b border-border min-w-[180px] sticky left-0 bg-muted`,children:`Vaccine`}),o.map(e=>(0,r.jsx)(`th`,{className:`px-2 py-2 border-b border-border text-center whitespace-nowrap`,children:e.label},e.id))]})}),(0,r.jsx)(`tbody`,{children:s.map(t=>(0,r.jsxs)(`tr`,{className:`even:bg-muted/20`,children:[(0,r.jsx)(`td`,{className:`px-3 py-2 border-b border-border font-medium sticky left-0 bg-card`,children:e.vaccineFullNames[t]||t}),o.map(n=>{let i=(e.periodicity[n.id].vaccines||[]).find(e=>e.vaccine===t);if(!i)return(0,r.jsx)(`td`,{className:`border-b border-border`},n.id);let a=typeof i.dose==`number`?`#`+i.dose:i.dose||``;return(0,r.jsx)(`td`,{className:`border-b border-border text-center bg-primary/10 font-mono text-[11px]`,title:i.notes||`${t} dose ${i.dose}`,children:a},n.id)})]},t))})]})}),(0,r.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Hover any filled cell for notes. Sources: AAP/Bright Futures (Feb 2025), CDC Child & Adolescent Immunization Schedule (2025).`})]})}export{i as default};
import{t as e}from"./jsx-runtime-ByY1xr43.js";import{i as t,l as n}from"./index-BuU36CvS.js";var r=e();function i(){let{data:e,isLoading:i,error:a}=n({queryKey:[`schedule-data`],queryFn:()=>t.get(`/api/schedule-data`)});if(i)return(0,r.jsx)(`div`,{className:`p-6 text-sm text-muted-foreground`,children:`Loading schedule…`});if(a)return(0,r.jsx)(`div`,{className:`p-6 text-sm text-destructive`,children:a.message});if(!e)return null;let o=e.visitAges.filter(t=>e.periodicity[t.id]?.vaccines?.length),s=[],c=new Set;return o.forEach(t=>{e.periodicity[t.id].vaccines.forEach(e=>{c.has(e.vaccine)||(c.add(e.vaccine),s.push(e.vaccine))})}),(0,r.jsxs)(`div`,{className:`max-w-full mx-auto p-6 space-y-4`,children:[(0,r.jsxs)(`header`,{children:[(0,r.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Vaccine Schedule`}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`AAP/ACIP 2025 complete immunization schedule (018 years).`})]}),(0,r.jsx)(`div`,{className:`rounded-lg border border-border overflow-auto bg-card`,children:(0,r.jsxs)(`table`,{className:`text-xs`,children:[(0,r.jsx)(`thead`,{className:`sticky top-0 bg-muted`,children:(0,r.jsxs)(`tr`,{children:[(0,r.jsx)(`th`,{className:`text-left font-semibold px-3 py-2 border-b border-border min-w-[180px] sticky left-0 bg-muted`,children:`Vaccine`}),o.map(e=>(0,r.jsx)(`th`,{className:`px-2 py-2 border-b border-border text-center whitespace-nowrap`,children:e.label},e.id))]})}),(0,r.jsx)(`tbody`,{children:s.map(t=>(0,r.jsxs)(`tr`,{className:`even:bg-muted/20`,children:[(0,r.jsx)(`td`,{className:`px-3 py-2 border-b border-border font-medium sticky left-0 bg-card`,children:e.vaccineFullNames[t]||t}),o.map(n=>{let i=(e.periodicity[n.id].vaccines||[]).find(e=>e.vaccine===t);if(!i)return(0,r.jsx)(`td`,{className:`border-b border-border`},n.id);let a=typeof i.dose==`number`?`#`+i.dose:i.dose||``;return(0,r.jsx)(`td`,{className:`border-b border-border text-center bg-primary/10 font-mono text-[11px]`,title:i.notes||`${t} dose ${i.dose}`,children:a},n.id)})]},t))})]})}),(0,r.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Hover any filled cell for notes. Sources: AAP/Bright Futures (Feb 2025), CDC Child & Adolescent Immunization Schedule (2025).`})]})}export{i as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,9 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DTb5Y4u0.js"></script>
<script type="module" crossorigin src="/app/assets/index-BuU36CvS.js"></script>
<link rel="modulepreload" crossorigin href="/app/assets/jsx-runtime-ByY1xr43.js">
<link rel="stylesheet" crossorigin href="/app/assets/index-CyWXS7DQ.css">
<link rel="stylesheet" crossorigin href="/app/assets/index-BEGt1l85.css">
</head>
<body>
<div id="root"></div>

View file

@ -195,25 +195,33 @@ function getTemplatedIndex() {
// Prime once at boot so first request is fast
getTemplatedIndex();
app.get(['/', '/index.html'], function(req, res) {
var html = getTemplatedIndex();
if (!html) return res.sendFile(INDEX_PATH);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('X-Build-Id', BUILD_ID);
res.send(html);
});
// Public endpoint for cache-bust debugging + build-info
app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
// React SPA at /app/* (served from public/app/). Any deep link the
// client renders (e.g. /app/extensions) falls through here so the
// React Router picks it up client-side. The plain express.static
// below serves hashed .js/.css asset files directly.
app.get('/app/*splat', function(req, res) {
res.sendFile(path.join(__dirname, 'public', 'app', 'index.html'));
});
// React SPA serves every non-API, non-static HTML request. The React
// bundle lives at public/app/index.html; its hashed script/link tags
// point at /app/assets/... which the express.static below serves.
// BrowserRouter no longer uses a basename, so /encounter, /bedside,
// /auth, /reset-password, etc. all land on the SPA.
var REACT_INDEX = path.join(__dirname, 'public', 'app', 'index.html');
function sendReactIndex(_req: unknown, res: any) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('X-Build-Id', BUILD_ID);
res.sendFile(REACT_INDEX);
}
app.get('/', sendReactIndex);
app.get('/index.html', sendReactIndex);
// Legacy /app/* URLs (bookmarks from the pre-root era) also serve the
// SPA. React Router treats /app/encounter the same as /encounter
// because there's no basename — but we still set a proper 200 rather
// than 404 so the one-page bundle can client-side-redirect if needed.
app.get('/app/*splat', sendReactIndex);
// Email landing pages — served by the SPA so the React router picks
// up ?token=xxx and renders the reset / verify component.
app.get('/reset-password', sendReactIndex);
app.get('/verify-email', sendReactIndex);
app.get('/auth', sendReactIndex);
app.use(loggingMiddleware);
app.use(express.static(path.join(__dirname, 'public'), {
@ -319,11 +327,17 @@ app.use('/api/admin/learning', require('./src/routes/learningAI'));
});
})();
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
// SPA fallback for any remaining GET that looks like a client-side
// route (not an API, not a known static file extension). Runs AFTER
// express.static so real files still win. Without this, a hard
// refresh on /encounter or /settings would 404.
app.get('*splat', function(req, res, next) {
if (req.method !== 'GET') return next();
if (req.path.startsWith('/api/')) return next();
// Let 404 handler deal with anything that looks like a static asset
// (preserves the existing "missing image/CSS = 404" behavior).
if (/\.[a-z0-9]{1,6}$/i.test(req.path)) return next();
return sendReactIndex(req, res);
});
// 404 handler — must be last

View file

@ -505,6 +505,16 @@ export interface LearningSlidesOk {
slides: string[]; // pre-rendered HTML per slide from the server
}
// Public config for the auth screen (anonymous users allowed).
// /api/auth/public-config
export interface PublicConfigOk {
registrationEnabled: boolean;
turnstileSiteKey: string | null;
oidcEnabled: boolean;
disableLocalAuth: boolean;
ssoButtonLabel: string;
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {
id: number;

View file

@ -689,6 +689,27 @@ router.get('/registration-status', async (req, res) => {
} catch (err) { res.json({ registrationEnabled: true }); }
});
// Public config the React auth screen needs up front — Turnstile site
// key, OIDC status, registration flag. Safe to serve to anonymous
// clients (all values are either public-by-design or feature flags).
router.get('/public-config', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
var oidcEnabled = await db.getSetting('oidc.enabled');
var disableLocal = await db.getSetting('oidc.disable_local_auth');
var buttonLabel = await db.getSetting('oidc.button_label');
res.json({
registrationEnabled: enabled !== 'false',
turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || null,
oidcEnabled: oidcEnabled === 'true',
disableLocalAuth: disableLocal === 'true',
ssoButtonLabel: buttonLabel || 'Sign in with SSO',
});
} catch (err) {
res.json({ registrationEnabled: true, turnstileSiteKey: null, oidcEnabled: false, disableLocalAuth: false });
}
});
// Expose helpers for adminConfig test-email and email template loading
module.exports = router;
module.exports.__sendEmail = sendEmail;