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.
1 line
No EOL
30 KiB
JavaScript
1 line
No EOL
30 KiB
JavaScript
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{c as r,i,l as a,u as o}from"./index-BuU36CvS.js";import{t as s}from"./ConfirmModal-zYA0ebt6.js";var c=e(t(),1),l=n(),u=`rounded-lg border border-border bg-card p-5 space-y-3`,d=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring`,f=`block text-xs font-medium text-muted-foreground`,p=`rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50`,m=`rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50`,h=`rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50`,g=`text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground`,_=`px-2 py-1.5 border-b border-border align-top text-sm`;function v({msg:e}){return e?(0,l.jsx)(`div`,{className:`text-sm `+(e.kind===`ok`?`text-green-600`:e.kind===`err`?`text-destructive`:`text-muted-foreground`),children:e.text}):null}function y(){let e=o(),[t,n]=(0,c.useState)(null),[f,y]=(0,c.useState)(``),[b,x]=(0,c.useState)(null),[S,C]=(0,c.useState)(null),[w,T]=(0,c.useState)(``),{data:E,isLoading:D,error:O}=a({queryKey:[`admin-users`],queryFn:()=>i.get(`/api/admin/users`)}),k=r({mutationFn:e=>i.post(`/api/admin/users/${e}/verify`,{}),onSuccess:t=>{n({text:t.message,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-users`]})},onError:e=>n({text:e.message,kind:`err`})}),A=r({mutationFn:e=>i.post(`/api/admin/users/${e}/disable`,{}),onSuccess:t=>{n({text:t.message,kind:`info`}),e.invalidateQueries({queryKey:[`admin-users`]})},onError:e=>n({text:e.message,kind:`err`})}),j=r({mutationFn:e=>i.post(`/api/admin/users/${e}/enable`,{}),onSuccess:t=>{n({text:t.message,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-users`]})},onError:e=>n({text:e.message,kind:`err`})}),M=r({mutationFn:e=>i.post(`/api/admin/users/${e.id}/role`,{role:e.role}),onSuccess:t=>{n({text:t.message,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-users`]})},onError:e=>n({text:e.message,kind:`err`})}),N=r({mutationFn:e=>i.delete(`/api/admin/users/${e}`),onSuccess:t=>{n({text:t.message,kind:`info`}),e.invalidateQueries({queryKey:[`admin-users`]})},onError:e=>n({text:e.message,kind:`err`})}),P=r({mutationFn:e=>i.post(`/api/admin/users/${e.id}/reset-password`,{newPassword:e.newPassword}),onSuccess:e=>{n({text:e.message,kind:`ok`}),C(null),T(``)},onError:e=>n({text:e.message,kind:`err`})}),F=(E?.users||[]).filter(e=>!f||e.email.toLowerCase().includes(f.toLowerCase())||e.name.toLowerCase().includes(f.toLowerCase()));return(0,l.jsxs)(`section`,{className:u,"data-testid":`admin-users-tab`,children:[(0,l.jsxs)(`div`,{className:`flex items-center justify-between gap-2 flex-wrap`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Users`}),(0,l.jsx)(`input`,{type:`search`,className:d+` max-w-xs`,placeholder:`Search by name or email…`,value:f,onChange:e=>y(e.target.value),"data-testid":`admin-users-search`})]}),D&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),O&&(0,l.jsx)(`div`,{className:`text-sm text-destructive`,children:O.message}),(0,l.jsx)(`div`,{className:`overflow-x-auto`,children:(0,l.jsxs)(`table`,{className:`w-full text-sm`,"data-testid":`admin-users-table`,children:[(0,l.jsx)(`thead`,{children:(0,l.jsxs)(`tr`,{children:[(0,l.jsx)(`th`,{className:g,children:`Email`}),(0,l.jsx)(`th`,{className:g,children:`Name`}),(0,l.jsx)(`th`,{className:g,children:`Role`}),(0,l.jsx)(`th`,{className:g,children:`Verified`}),(0,l.jsx)(`th`,{className:g,children:`2FA`}),(0,l.jsx)(`th`,{className:g,children:`Status`}),(0,l.jsx)(`th`,{className:g,children:`Actions`})]})}),(0,l.jsxs)(`tbody`,{children:[F.map(e=>(0,l.jsxs)(`tr`,{"data-testid":`admin-user-row-${e.id}`,className:e.disabled?`opacity-60`:``,children:[(0,l.jsx)(`td`,{className:_,children:e.email}),(0,l.jsx)(`td`,{className:_,children:e.name}),(0,l.jsx)(`td`,{className:_,children:(0,l.jsxs)(`select`,{className:d+` text-xs w-28`,value:e.role||`user`,onChange:t=>M.mutate({id:e.id,role:t.target.value}),"data-testid":`admin-user-role-${e.id}`,children:[(0,l.jsx)(`option`,{value:`user`,children:`user`}),(0,l.jsx)(`option`,{value:`moderator`,children:`moderator`}),(0,l.jsx)(`option`,{value:`admin`,children:`admin`})]})}),(0,l.jsx)(`td`,{className:_+` text-xs`,children:e.email_verified?`✅`:(0,l.jsx)(`button`,{type:`button`,className:m,onClick:()=>k.mutate(e.id),"data-testid":`admin-user-verify-${e.id}`,children:`Verify`})}),(0,l.jsx)(`td`,{className:_+` text-xs`,children:e.totp_enabled?`✅`:`—`}),(0,l.jsx)(`td`,{className:_+` text-xs`,children:e.disabled?(0,l.jsx)(`button`,{type:`button`,className:m,onClick:()=>j.mutate(e.id),"data-testid":`admin-user-enable-${e.id}`,children:`Enable`}):(0,l.jsx)(`button`,{type:`button`,className:m,onClick:()=>A.mutate(e.id),"data-testid":`admin-user-disable-${e.id}`,children:`Disable`})}),(0,l.jsx)(`td`,{className:_+` text-xs`,children:(0,l.jsxs)(`div`,{className:`flex gap-1`,children:[(0,l.jsx)(`button`,{type:`button`,className:m,onClick:()=>C(e),"data-testid":`admin-user-reset-${e.id}`,children:`Reset pw`}),(0,l.jsx)(`button`,{type:`button`,className:h,onClick:()=>x(e),"data-testid":`admin-user-delete-${e.id}`,children:`Delete`})]})})]},e.id)),F.length===0&&E&&(0,l.jsx)(`tr`,{children:(0,l.jsxs)(`td`,{className:_+` text-muted-foreground italic`,colSpan:7,children:[`No users match "`,f,`".`]})})]})]})}),(0,l.jsx)(v,{msg:t}),(0,l.jsx)(s,{open:!!b,title:`Delete ${b?.email}?`,body:`This deletes the user account. Audit log entries are preserved (user_id set to NULL).`,confirmText:`Delete`,danger:!0,busy:N.isPending,onConfirm:()=>{b&&N.mutate(b.id),x(null)},onCancel:()=>x(null)}),S&&(0,l.jsx)(`div`,{role:`dialog`,"aria-modal":`true`,className:`fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4`,onClick:()=>C(null),children:(0,l.jsxs)(`div`,{className:`w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3`,onClick:e=>e.stopPropagation(),children:[(0,l.jsxs)(`h3`,{className:`text-base font-semibold`,children:[`Reset password for `,S.email]}),(0,l.jsx)(`input`,{type:`text`,className:d,placeholder:`New password (8+ chars)`,value:w,onChange:e=>T(e.target.value),autoFocus:!0,minLength:8,"data-testid":`admin-user-reset-input`}),(0,l.jsxs)(`div`,{className:`flex justify-end gap-2`,children:[(0,l.jsx)(`button`,{type:`button`,className:m,onClick:()=>{C(null),T(``)},children:`Cancel`}),(0,l.jsx)(`button`,{type:`button`,className:p,disabled:w.length<8||P.isPending,onClick:()=>P.mutate({id:S.id,newPassword:w}),"data-testid":`admin-user-reset-submit`,children:P.isPending?`Saving…`:`Reset`})]})]})})]})}function b(){let e=o(),[t,n]=(0,c.useState)(null),{data:s}=a({queryKey:[`admin-settings`],queryFn:()=>i.get(`/api/admin/settings`)}),d=r({mutationFn:e=>i.post(`/api/admin/settings/registration`,{enabled:e}),onSuccess:t=>{n({text:t.message,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-settings`]})},onError:e=>n({text:e.message,kind:`err`})});return(0,l.jsxs)(`section`,{className:u,"data-testid":`admin-settings-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Site settings`}),s&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsxs)(`div`,{className:`grid grid-cols-3 gap-3`,children:[(0,l.jsxs)(`div`,{className:`rounded-md bg-muted/40 p-3`,children:[(0,l.jsx)(`div`,{className:`text-xs uppercase text-muted-foreground`,children:`Total users`}),(0,l.jsx)(`div`,{className:`text-xl font-bold`,children:s.stats.totalUsers})]}),(0,l.jsxs)(`div`,{className:`rounded-md bg-muted/40 p-3`,children:[(0,l.jsx)(`div`,{className:`text-xs uppercase text-muted-foreground`,children:`API calls (all time)`}),(0,l.jsx)(`div`,{className:`text-xl font-bold`,children:s.stats.totalApiCalls})]}),(0,l.jsxs)(`div`,{className:`rounded-md bg-muted/40 p-3`,children:[(0,l.jsx)(`div`,{className:`text-xs uppercase text-muted-foreground`,children:`API calls (today)`}),(0,l.jsx)(`div`,{className:`text-xl font-bold`,children:s.stats.todayApiCalls})]})]}),(0,l.jsx)(`div`,{className:`flex items-center gap-3`,children:(0,l.jsxs)(`label`,{className:`flex items-center gap-2 cursor-pointer`,children:[(0,l.jsx)(`input`,{type:`checkbox`,className:`accent-primary size-4`,checked:s.settings.registrationEnabled,onChange:e=>d.mutate(e.target.checked),"data-testid":`admin-registration-toggle`}),(0,l.jsx)(`span`,{className:`text-sm font-medium`,children:`Allow new user registration`})]})})]}),(0,l.jsx)(v,{msg:t})]})}function x(){let e=o(),[t,n]=(0,c.useState)(null),[s,m]=(0,c.useState)(!1),[h,g]=(0,c.useState)(`info`),[_,y]=(0,c.useState)(``),[b,x]=(0,c.useState)(!1),{data:S}=a({queryKey:[`admin-announcement`],queryFn:()=>i.get(`/api/admin/config/announcement`)});!b&&S&&(m(S.enabled),g(S.type||`info`),y(S.text||``),x(!0));let C=r({mutationFn:async e=>i.put(`/api/admin/config/${encodeURIComponent(e.key)}`,{value:e.value})});async function w(){n(null);try{await Promise.all([C.mutateAsync({key:`announcement.enabled`,value:s?`true`:`false`}),C.mutateAsync({key:`announcement.type`,value:h}),C.mutateAsync({key:`announcement.text`,value:_})]),n({text:`Announcement saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-announcement`]})}catch(e){n({text:e.message,kind:`err`})}}return(0,l.jsxs)(`section`,{className:u,"data-testid":`admin-announcement-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Announcement banner`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Shown at the top of every page when enabled. Use for scheduled maintenance, outage notices, or release notes.`}),(0,l.jsx)(`div`,{className:`flex items-center gap-3`,children:(0,l.jsxs)(`label`,{className:`flex items-center gap-2 cursor-pointer`,children:[(0,l.jsx)(`input`,{type:`checkbox`,className:`accent-primary size-4`,checked:s,onChange:e=>m(e.target.checked),"data-testid":`admin-announcement-enabled`}),(0,l.jsx)(`span`,{className:`text-sm`,children:`Show banner`})]})}),(0,l.jsxs)(`div`,{className:`grid gap-3 sm:grid-cols-3`,children:[(0,l.jsxs)(`div`,{className:`sm:col-span-1`,children:[(0,l.jsx)(`label`,{className:f,children:`Severity`}),(0,l.jsxs)(`select`,{className:d,value:h,onChange:e=>g(e.target.value),"data-testid":`admin-announcement-type`,children:[(0,l.jsx)(`option`,{value:`info`,children:`Info (blue)`}),(0,l.jsx)(`option`,{value:`warning`,children:`Warning (amber)`}),(0,l.jsx)(`option`,{value:`critical`,children:`Critical (red)`})]})]}),(0,l.jsxs)(`div`,{className:`sm:col-span-2`,children:[(0,l.jsx)(`label`,{className:f,children:`Message`}),(0,l.jsx)(`textarea`,{rows:3,className:d+` resize-y`,value:_,onChange:e=>y(e.target.value),placeholder:`e.g. Scheduled maintenance Thursday 02:00 UTC — expect 10 minutes of downtime.`,"data-testid":`admin-announcement-text`})]})]}),(0,l.jsx)(`button`,{type:`button`,onClick:w,disabled:C.isPending,className:p,"data-testid":`admin-announcement-save`,children:C.isPending?`Saving…`:`Save announcement`}),(0,l.jsx)(v,{msg:t})]})}var S=`rounded-lg border border-border bg-card p-5 space-y-3`,C=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring`,w=`block text-xs font-medium text-muted-foreground`,T=`rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50`,E=`rounded-md border border-border bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50`,D=`rounded-md bg-destructive text-white px-3 py-2 text-xs font-medium disabled:opacity-50`,O=`text-left px-2 py-1.5 border-b border-border font-semibold uppercase tracking-wide text-[10px] text-muted-foreground`,k=`px-2 py-1.5 border-b border-border align-top text-sm`;function A({msg:e}){return e?(0,l.jsx)(`div`,{className:`text-sm `+(e.kind===`ok`?`text-green-600`:e.kind===`err`?`text-destructive`:`text-muted-foreground`),children:e.text}):null}function j(){return r({mutationFn:e=>i.put(`/api/admin/config/${encodeURIComponent(e.key)}`,{value:e.value})})}function M(){let e=o(),[t,n]=(0,c.useState)(null),[u,d]=(0,c.useState)(!1),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(`587`),[g,_]=(0,c.useState)(``),[v,y]=(0,c.useState)(``),[b,x]=(0,c.useState)(``),[O,k]=(0,c.useState)(`false`),[j,M]=(0,c.useState)(!1),[N,P]=(0,c.useState)(``),[F,I]=(0,c.useState)(`verify`),{data:L}=a({queryKey:[`admin-smtp-status`],queryFn:()=>i.get(`/api/admin/config/smtp/status`)});!j&&L&&(p(L.host||``),h(String(L.port??`587`)),_(L.user||``),x(L.from||``),M(!0));let R=r({mutationFn:e=>i.put(`/api/admin/config/smtp`,e),onSuccess:()=>{n({text:`SMTP settings saved`,kind:`ok`}),y(``),e.invalidateQueries({queryKey:[`admin-smtp-status`]})},onError:e=>n({text:e.message,kind:`err`})}),z=r({mutationFn:()=>i.delete(`/api/admin/config/smtp`),onSuccess:t=>{n({text:t.message,kind:`info`}),e.invalidateQueries({queryKey:[`admin-smtp-status`]})},onError:e=>n({text:e.message,kind:`err`})}),B=r({mutationFn:e=>i.post(`/api/admin/config/test-email`,e),onSuccess:()=>n({text:`Test email sent to ${N}`,kind:`ok`}),onError:e=>n({text:e.message,kind:`err`})});return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-smtp-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`SMTP`}),L&&(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[`Status: `,L.configured?`✅ Configured`:`❌ Not configured`,L.source&&(0,l.jsxs)(l.Fragment,{children:[` · source: `,(0,l.jsx)(`strong`,{children:L.source})]})]}),(0,l.jsxs)(`div`,{className:`grid gap-3 sm:grid-cols-2`,children:[(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Host`}),(0,l.jsx)(`input`,{className:C,value:f,onChange:e=>p(e.target.value),placeholder:`smtp.example.com`,"data-testid":`smtp-host`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Port`}),(0,l.jsx)(`input`,{className:C,value:m,onChange:e=>h(e.target.value),placeholder:`587`,"data-testid":`smtp-port`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Username`}),(0,l.jsx)(`input`,{className:C,value:g,onChange:e=>_(e.target.value),"data-testid":`smtp-user`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Password`}),(0,l.jsx)(`input`,{type:`password`,className:C,value:v,onChange:e=>y(e.target.value),placeholder:`Leave blank to keep existing`,"data-testid":`smtp-pass`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`From`}),(0,l.jsx)(`input`,{className:C,value:b,onChange:e=>x(e.target.value),placeholder:`noreply@example.com`,"data-testid":`smtp-from`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Secure (TLS)`}),(0,l.jsxs)(`select`,{className:C,value:O,onChange:e=>k(e.target.value),"data-testid":`smtp-secure`,children:[(0,l.jsx)(`option`,{value:`false`,children:`STARTTLS (587)`}),(0,l.jsx)(`option`,{value:`true`,children:`SSL/TLS (465)`})]})]})]}),(0,l.jsxs)(`div`,{className:`flex gap-2 flex-wrap`,children:[(0,l.jsx)(`button`,{type:`button`,className:T,disabled:R.isPending||!f,onClick:()=>R.mutate({host:f,port:m,user:g,pass:v,from:b,secure:O===`true`}),"data-testid":`smtp-save`,children:R.isPending?`Saving…`:`Save SMTP settings`}),(0,l.jsx)(`button`,{type:`button`,className:D,onClick:()=>d(!0),"data-testid":`smtp-clear`,children:`Clear DB override`})]}),(0,l.jsxs)(`div`,{className:`rounded-md bg-muted/40 p-3 space-y-2`,children:[(0,l.jsx)(`div`,{className:`text-sm font-semibold`,children:`Send test email`}),(0,l.jsxs)(`div`,{className:`flex flex-wrap gap-2 items-end`,children:[(0,l.jsxs)(`div`,{className:`flex-1 min-w-[200px]`,children:[(0,l.jsx)(`label`,{className:w,children:`Recipient`}),(0,l.jsx)(`input`,{type:`email`,className:C,value:N,onChange:e=>P(e.target.value),placeholder:`recipient@example.com`,"data-testid":`smtp-test-to`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Template`}),(0,l.jsxs)(`select`,{className:C,value:F,onChange:e=>I(e.target.value),"data-testid":`smtp-test-template`,children:[(0,l.jsx)(`option`,{value:`verify`,children:`verify`}),(0,l.jsx)(`option`,{value:`reset`,children:`reset`}),(0,l.jsx)(`option`,{value:`password-changed`,children:`password-changed`})]})]}),(0,l.jsx)(`button`,{type:`button`,className:E,disabled:B.isPending||!N,onClick:()=>B.mutate({to:N,template:F}),"data-testid":`smtp-test-send`,children:B.isPending?`Sending…`:`Send test`})]})]}),(0,l.jsx)(A,{msg:t}),(0,l.jsx)(s,{open:u,title:`Clear DB SMTP settings?`,body:`Removes smtp.* entries from the DB. Env vars will still apply if they're set (e.g. SMTP_HOST from OpenBao).`,confirmText:`Clear`,danger:!0,busy:z.isPending,onConfirm:()=>{z.mutate(),d(!1)},onCancel:()=>d(!1)})]})}var N=[`verify`,`reset`,`password-changed`];function P(){let e=o(),[t,n]=(0,c.useState)(null),[r,s]=(0,c.useState)(`verify`),[u,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),{data:m}=a({queryKey:[`admin-config`],queryFn:()=>i.get(`/api/admin/config`)});function h(e){s(e);let t=new Map((m?.config||[]).map(e=>[e.key,e.value||``]));d(t.get(`email.`+e+`.subject`)||``),p(t.get(`email.`+e+`.body`)||``)}m&&!u&&!f&&h(r);let g=j();async function _(){n(null);try{await Promise.all([g.mutateAsync({key:`email.`+r+`.subject`,value:u}),g.mutateAsync({key:`email.`+r+`.body`,value:f})]),n({text:`Email template saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-config`]})}catch(e){n({text:e.message,kind:`err`})}}return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-email-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Email templates`}),(0,l.jsxs)(`div`,{className:`max-w-xs`,children:[(0,l.jsx)(`label`,{className:w,children:`Template`}),(0,l.jsx)(`select`,{className:C,value:r,onChange:e=>h(e.target.value),"data-testid":`email-template`,children:N.map(e=>(0,l.jsx)(`option`,{value:e,children:e},e))})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Subject`}),(0,l.jsx)(`input`,{className:C,value:u,onChange:e=>d(e.target.value),"data-testid":`email-subject`})]}),(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`label`,{className:w,children:`Body (HTML)`}),(0,l.jsx)(`textarea`,{rows:10,className:C+` resize-y font-mono text-xs`,value:f,onChange:e=>p(e.target.value),"data-testid":`email-body`})]}),(0,l.jsx)(`button`,{type:`button`,className:T,disabled:g.isPending,onClick:_,"data-testid":`email-save`,children:g.isPending?`Saving…`:`Save template`}),(0,l.jsx)(A,{msg:t})]})}function F(){let e=o(),[t,n]=(0,c.useState)(null),[u,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(!1),{data:g}=a({queryKey:[`admin-prompts`],queryFn:()=>i.get(`/api/admin/config/prompts`)});g&&!u&&g.prompts.length>0&&(d(g.prompts[0].key),p(g.prompts[0].value));function _(e){d(e);let t=g?.prompts.find(t=>t.key===e);p(t?.value||``)}let v=j(),y=r({mutationFn:e=>i.post(`/api/admin/config/prompts/${encodeURIComponent(e)}/reset`,{}),onSuccess:t=>{p(t.value),n({text:`Prompt reset to default`,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-prompts`]})},onError:e=>n({text:e.message,kind:`err`})});async function b(){n(null);try{await v.mutateAsync({key:`prompt.`+u,value:f}),n({text:`Prompt saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-prompts`]})}catch(e){n({text:e.message,kind:`err`})}}return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-prompts-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`AI Prompts`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`System prompts injected before each generation. Reset restores the hardcoded default from src/utils/prompts.ts.`}),(0,l.jsxs)(`div`,{className:`max-w-md`,children:[(0,l.jsx)(`label`,{className:w,children:`Prompt`}),(0,l.jsx)(`select`,{className:C,value:u,onChange:e=>_(e.target.value),"data-testid":`prompts-select`,children:(g?.prompts||[]).map(e=>(0,l.jsx)(`option`,{value:e.key,children:e.key},e.key))})]}),(0,l.jsx)(`textarea`,{rows:14,className:C+` resize-y font-mono text-xs`,value:f,onChange:e=>p(e.target.value),"data-testid":`prompts-text`}),(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[(0,l.jsx)(`button`,{type:`button`,className:T,disabled:v.isPending||!u,onClick:b,"data-testid":`prompts-save`,children:v.isPending?`Saving…`:`Save prompt`}),(0,l.jsx)(`button`,{type:`button`,className:E,disabled:!u,onClick:()=>h(!0),"data-testid":`prompts-reset`,children:`Reset to default`})]}),(0,l.jsx)(A,{msg:t}),(0,l.jsx)(s,{open:m,title:`Reset "${u}"?`,body:`Restores the hardcoded default. Cannot be undone.`,confirmText:`Reset`,danger:!0,busy:y.isPending,onConfirm:()=>{y.mutate(u),h(!1)},onCancel:()=>h(!1)})]})}function I(){let e=o(),[t,n]=(0,c.useState)(null),{data:s}=a({queryKey:[`admin-models`],queryFn:()=>i.get(`/api/admin/config/models`)}),u=r({mutationFn:e=>i.put(`/api/admin/config/models/toggle`,e),onSuccess:()=>e.invalidateQueries({queryKey:[`admin-models`]}),onError:e=>n({text:e.message,kind:`err`})}),d=r({mutationFn:e=>i.put(`/api/admin/config/models/default`,{modelId:e}),onSuccess:(t,r)=>{n({text:`Default model set to ${r}`,kind:`ok`}),e.invalidateQueries({queryKey:[`admin-models`]})},onError:e=>n({text:e.message,kind:`err`})});return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-models-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`AI Models`}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[`Active provider: `,(0,l.jsx)(`strong`,{children:s?.provider||`—`}),s?.defaultModel&&(0,l.jsxs)(l.Fragment,{children:[` · Default: `,(0,l.jsx)(`strong`,{children:s.defaultModel})]})]}),s?.litellmHint&&(0,l.jsx)(`div`,{className:`rounded-md bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-100`,children:`LiteLLM provider has no built-in model list — use the legacy "Discover" flow to populate.`}),(0,l.jsx)(`div`,{className:`overflow-x-auto`,children:(0,l.jsxs)(`table`,{className:`w-full text-sm`,"data-testid":`admin-models-table`,children:[(0,l.jsx)(`thead`,{children:(0,l.jsxs)(`tr`,{children:[(0,l.jsx)(`th`,{className:O,children:`Enabled`}),(0,l.jsx)(`th`,{className:O,children:`Default`}),(0,l.jsx)(`th`,{className:O,children:`Model ID`}),(0,l.jsx)(`th`,{className:O,children:`Label`})]})}),(0,l.jsxs)(`tbody`,{children:[(s?.models||[]).map(e=>(0,l.jsxs)(`tr`,{"data-testid":`admin-model-row-${e.id}`,children:[(0,l.jsx)(`td`,{className:k,children:(0,l.jsx)(`input`,{type:`checkbox`,className:`accent-primary size-4`,checked:e.enabled,onChange:t=>u.mutate({id:e.id,enabled:t.target.checked})})}),(0,l.jsx)(`td`,{className:k,children:(0,l.jsx)(`input`,{type:`radio`,name:`default-model`,checked:s?.defaultModel===e.id,onChange:()=>d.mutate(e.id),disabled:!e.enabled})}),(0,l.jsx)(`td`,{className:k+` font-mono text-xs`,children:e.id}),(0,l.jsx)(`td`,{className:k,children:e.label||`—`})]},e.id)),(s?.models||[]).length===0&&(0,l.jsx)(`tr`,{children:(0,l.jsx)(`td`,{className:k+` italic text-muted-foreground`,colSpan:4,children:`No models available.`})})]})]})}),(0,l.jsx)(`div`,{className:`text-xs text-muted-foreground italic`,children:`Model discovery (search + add-custom) still lives in the legacy viewer — ports when the provider integration is revamped.`}),(0,l.jsx)(A,{msg:t})]})}function L(e){return a({queryKey:[`voice-provider`,e],queryFn:()=>i.get(e)})}function R(){let e=o(),[t,n]=(0,c.useState)(null),{data:r}=L(`/api/admin/config/tts`),i=j(),[a,s]=(0,c.useState)(``);r&&a===``&&r.defaultVoice&&s(r.defaultVoice);async function u(){n(null);try{await i.mutateAsync({key:`tts.default_voice`,value:a}),n({text:`Default TTS voice saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`voice-provider`,`/api/admin/config/tts`]})}catch(e){n({text:e.message,kind:`err`})}}return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-tts-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`TTS Provider`}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[`Active provider: `,(0,l.jsx)(`strong`,{children:r?.provider||`—`})]}),(0,l.jsxs)(`div`,{className:`max-w-md`,children:[(0,l.jsx)(`label`,{className:w,children:`Default voice`}),(0,l.jsxs)(`select`,{className:C,value:a,onChange:e=>s(e.target.value),"data-testid":`admin-tts-voice`,children:[(0,l.jsx)(`option`,{value:``,children:`(none)`}),(r?.voices||[]).map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label||e.value},e.value))]})]}),(0,l.jsx)(`button`,{type:`button`,className:T,disabled:i.isPending,onClick:u,"data-testid":`admin-tts-save`,children:i.isPending?`Saving…`:`Save default voice`}),(0,l.jsx)(A,{msg:t})]})}function z(){let e=o(),[t,n]=(0,c.useState)(null),{data:r}=L(`/api/admin/config/stt`),i=j(),[a,s]=(0,c.useState)(``);r&&a===``&&r.defaultModel&&s(r.defaultModel);async function u(){n(null);try{await i.mutateAsync({key:`stt.default_model`,value:a}),n({text:`Default STT model saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`voice-provider`,`/api/admin/config/stt`]})}catch(e){n({text:e.message,kind:`err`})}}return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-stt-tab`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`STT Provider`}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[`Active provider: `,(0,l.jsx)(`strong`,{children:r?.provider||`—`})]}),(0,l.jsxs)(`div`,{className:`max-w-md`,children:[(0,l.jsx)(`label`,{className:w,children:`Default STT model`}),(0,l.jsxs)(`select`,{className:C,value:a,onChange:e=>s(e.target.value),"data-testid":`admin-stt-model`,children:[(0,l.jsx)(`option`,{value:``,children:`(none)`}),(r?.models||[]).map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label||e.value},e.value))]})]}),(0,l.jsx)(`button`,{type:`button`,className:T,disabled:i.isPending,onClick:u,"data-testid":`admin-stt-save`,children:i.isPending?`Saving…`:`Save default model`}),(0,l.jsx)(A,{msg:t})]})}var B=[``,`auth`,`admin`,`clinical`,`export`,`integration`,`documents`];function V(){let[e,t]=(0,c.useState)(``),[n,r]=(0,c.useState)(100),{data:o,isLoading:s,error:u,refetch:d}=a({queryKey:[`admin-logs`,e,n],queryFn:()=>i.get(`/api/admin/logs/all?limit=${n}${e?`&category=`+encodeURIComponent(e):``}`)});return(0,l.jsxs)(`section`,{className:S,"data-testid":`admin-logs-tab`,children:[(0,l.jsxs)(`div`,{className:`flex items-center justify-between gap-2 flex-wrap`,children:[(0,l.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Audit Logs`}),(0,l.jsxs)(`div`,{className:`flex gap-2 items-center`,children:[(0,l.jsx)(`label`,{className:w,children:`Category`}),(0,l.jsx)(`select`,{className:C+` w-36 text-xs`,value:e,onChange:e=>t(e.target.value),"data-testid":`admin-logs-category`,children:B.map(e=>(0,l.jsx)(`option`,{value:e,children:e||`(all)`},e))}),(0,l.jsx)(`label`,{className:w,children:`Limit`}),(0,l.jsx)(`select`,{className:C+` w-24 text-xs`,value:n,onChange:e=>r(Number(e.target.value)),"data-testid":`admin-logs-limit`,children:[50,100,200,500].map(e=>(0,l.jsx)(`option`,{value:e,children:e},e))}),(0,l.jsx)(`button`,{type:`button`,className:E,onClick:()=>d(),children:`Refresh`})]})]}),s&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),u&&(0,l.jsx)(`div`,{className:`text-sm text-destructive`,children:u.message}),(0,l.jsx)(`div`,{className:`overflow-x-auto max-h-[70vh] overflow-y-auto`,children:(0,l.jsxs)(`table`,{className:`w-full text-sm`,"data-testid":`admin-logs-table`,children:[(0,l.jsx)(`thead`,{className:`sticky top-0 bg-card`,children:(0,l.jsxs)(`tr`,{children:[(0,l.jsx)(`th`,{className:O,children:`Time`}),(0,l.jsx)(`th`,{className:O,children:`User`}),(0,l.jsx)(`th`,{className:O,children:`Category`}),(0,l.jsx)(`th`,{className:O,children:`Action`}),(0,l.jsx)(`th`,{className:O,children:`Detail`}),(0,l.jsx)(`th`,{className:O,children:`IP`})]})}),(0,l.jsx)(`tbody`,{children:(o?.logs||[]).map(e=>(0,l.jsxs)(`tr`,{children:[(0,l.jsx)(`td`,{className:k+` text-xs whitespace-nowrap`,children:new Date(e.timestamp).toLocaleString()}),(0,l.jsxs)(`td`,{className:k+` text-xs`,children:[e.user_email||`—`,e.user_name?` (${e.user_name})`:``]}),(0,l.jsx)(`td`,{className:k+` text-xs`,children:e.category}),(0,l.jsx)(`td`,{className:k+` text-xs font-mono`,children:e.action}),(0,l.jsx)(`td`,{className:k+` text-xs`,children:e.detail}),(0,l.jsx)(`td`,{className:k+` text-xs text-muted-foreground`,children:e.ip_address||``})]},e.id))})]})})]})}var H=`rounded-lg border border-border bg-card p-5 space-y-3`,U=[{id:`users`,label:`Users`},{id:`settings`,label:`Site settings`},{id:`announcement`,label:`Announcement`},{id:`models`,label:`AI models`},{id:`tts`,label:`TTS provider`},{id:`stt`,label:`STT provider`},{id:`smtp`,label:`SMTP`},{id:`email`,label:`Email templates`},{id:`prompts`,label:`AI prompts`},{id:`logs`,label:`Audit logs`}];function W(){let{data:e,isLoading:t}=a({queryKey:[`auth-me`],queryFn:()=>i.get(`/api/auth/me`),staleTime:5*6e4}),[n,r]=(0,c.useState)(`users`);return t?(0,l.jsx)(`div`,{className:`max-w-3xl mx-auto p-6 text-sm text-muted-foreground`,children:`Checking permissions…`}):e?.user.role===`admin`?(0,l.jsxs)(`div`,{className:`max-w-6xl mx-auto p-6 space-y-4`,"data-testid":`admin-shell`,children:[(0,l.jsxs)(`header`,{children:[(0,l.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Admin Panel`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Users, site settings, announcement banner, AI model management, TTS/STT provider, SMTP, email templates, and audit logs.`})]}),(0,l.jsx)(`div`,{className:`flex flex-wrap gap-2`,"data-testid":`admin-subnav`,children:U.map(e=>(0,l.jsx)(`button`,{type:`button`,onClick:()=>r(e.id),className:`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors `+(n===e.id?`bg-primary text-primary-foreground border-primary`:`bg-muted hover:bg-muted/80 border-border`),"data-testid":`admin-tab-`+e.id,children:e.label},e.id))}),n===`users`&&(0,l.jsx)(y,{}),n===`settings`&&(0,l.jsx)(b,{}),n===`announcement`&&(0,l.jsx)(x,{}),n===`models`&&(0,l.jsx)(I,{}),n===`tts`&&(0,l.jsx)(R,{}),n===`stt`&&(0,l.jsx)(z,{}),n===`smtp`&&(0,l.jsx)(M,{}),n===`email`&&(0,l.jsx)(P,{}),n===`prompts`&&(0,l.jsx)(F,{}),n===`logs`&&(0,l.jsx)(V,{})]}):(0,l.jsx)(`div`,{className:`max-w-3xl mx-auto p-6`,children:(0,l.jsxs)(`section`,{className:H,"data-testid":`admin-access-denied`,children:[(0,l.jsx)(`h1`,{className:`text-xl font-semibold`,children:`Admin only`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`This page is restricted to users with the admin role. If you believe this is a mistake, contact your site administrator.`})]})})}export{W as default}; |