Closes the last two Learning Hub follow-ups flagged in the earlier
commit body: rich-HTML body rendering (instead of pre-wrap plain text)
and in-React Marp slide playback (instead of the legacy-viewer link).
client/src/lib/sanitize.ts (new)
Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts
run), walks the tree and:
• Removes script / style / iframe / object / embed / link / meta /
base / form / input / button / select / textarea.
• Drops every on* event-handler attribute.
• Drops href / src / xlink:href values starting with javascript:
or data:text/html.
• Drops any attribute whose value contains "javascript:".
• Falls back to entity-escaping the raw string if parsing throws.
Admin-authored Learning Hub content is the trust model here —
essentially CMS content. A full DOMPurify dep would be strictly
better but adding a package requires a network install; the inline
sanitizer covers the realistic XSS vectors without the dep bump.
client/src/pages/Learning.tsx
• Body rendering (non-presentation content_type) now runs through
sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm`
Tailwind-typography class. Markdown/HTML formatting from admin
content now appears correctly (headings, lists, code, bold,
italics, links) instead of raw text.
• SlideViewer component (new) replaces the "Open in legacy viewer"
button for content_type === 'presentation'. Fetches
/api/learning/content/:slug/slides (returns { css, slides[] } —
server-side Marp output), sanitizes each slide's HTML + the CSS
block, renders one slide at a time with:
- prev/next buttons
- keyboard ←/→ and PageUp/PageDown navigation
- slide counter (N/total)
- fullscreen toggle (Escape exits)
Marp's own CSS is injected scoped-ish via sanitizer so slide
theming survives.
shared/types.ts + client/src/shared/types.ts — additive:
LearningSlidesOk { css: string; slides: string[] }
Client tsc + vite build clean. Initial bundle unchanged
(342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded;
the sanitizer + SlideViewer roll into its chunk.
4 lines
No EOL
41 KiB
JavaScript
4 lines
No EOL
41 KiB
JavaScript
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,o as a,s as o}from"./index-DTb5Y4u0.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`,f=`rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50`,p=`rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50`,m=`rounded-md bg-destructive text-white px-3 py-2 text-sm font-medium disabled:opacity-50`;function h({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 g(e){let t=Math.floor((Date.now()-new Date(e).getTime())/1e3);return t<60?`just now`:t<3600?Math.floor(t/60)+`m ago`:t<86400?Math.floor(t/3600)+`h ago`:Math.floor(t/86400)+`d ago`}function _(){let e=o(),[t,n]=(0,c.useState)(``),[a,s]=(0,c.useState)(``),[p,m]=(0,c.useState)(``),[g,_]=(0,c.useState)(null),v=r({mutationFn:e=>i.post(`/api/auth/change-password`,e),onSuccess:t=>{_({text:t.message||`Password changed`,kind:`ok`}),n(``),s(``),m(``),e.invalidateQueries({queryKey:[`sessions`]}),t.passwordWarning&&setTimeout(()=>_({text:t.passwordWarning,kind:`info`}),2e3)},onError:e=>_({text:e.message,kind:`err`})});function y(e){if(e.preventDefault(),_(null),!t||!a)return _({text:`Fill in all fields`,kind:`err`});if(a.length<8)return _({text:`New password must be 8+ characters`,kind:`err`});if(a!==p)return _({text:`Passwords do not match`,kind:`err`});v.mutate({currentPassword:t,newPassword:a})}return(0,l.jsxs)(`section`,{className:u,"data-testid":`change-password-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Change Password`}),(0,l.jsxs)(`form`,{onSubmit:y,className:`space-y-2 max-w-sm`,children:[(0,l.jsx)(`input`,{type:`password`,className:d,placeholder:`Current password`,value:t,onChange:e=>n(e.target.value),"data-testid":`pw-current`}),(0,l.jsx)(`input`,{type:`password`,className:d,placeholder:`New password (8+ characters)`,minLength:8,value:a,onChange:e=>s(e.target.value),"data-testid":`pw-new`}),(0,l.jsx)(`input`,{type:`password`,className:d,placeholder:`Confirm new password`,minLength:8,value:p,onChange:e=>m(e.target.value),"data-testid":`pw-confirm`}),(0,l.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,l.jsx)(`button`,{type:`submit`,disabled:v.isPending,className:f,"data-testid":`btn-change-password`,children:v.isPending?`Changing…`:`Change Password`}),(0,l.jsx)(h,{msg:g})]})]})]})}function v({codes:e,onClose:t}){return(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`,children:(0,l.jsxs)(`div`,{className:`w-full max-w-md rounded-lg border border-border bg-background p-5 shadow-lg space-y-3`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Save your backup codes`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Each code can be used once if you lose access to your authenticator. They will not be shown again.`}),(0,l.jsx)(`pre`,{className:`bg-muted p-3 rounded text-sm font-mono whitespace-pre-wrap break-all`,children:e.join(`
|
|
`)}),(0,l.jsxs)(`div`,{className:`flex justify-end gap-2`,children:[(0,l.jsx)(`button`,{type:`button`,onClick:()=>navigator.clipboard?.writeText(e.join(`
|
|
`)),className:p,children:`Copy`}),(0,l.jsx)(`button`,{type:`button`,onClick:t,className:f,children:`I've saved them`})]})]})})}function y({user:e}){let t=o(),n=e.totp_enabled===!0,[g,_]=(0,c.useState)(`idle`),[y,b]=(0,c.useState)(null),[x,S]=(0,c.useState)(``),[C,w]=(0,c.useState)(``),[T,E]=(0,c.useState)(null),[D,O]=(0,c.useState)(!1),[k,A]=(0,c.useState)(null),{data:j}=a({queryKey:[`2fa-backup-count`],queryFn:()=>i.get(`/api/auth/2fa/backup-codes/count`),enabled:n}),M=r({mutationFn:()=>i.post(`/api/auth/setup-2fa`,{}),onSuccess:e=>{b(e),_(`setup`),A(null)},onError:e=>A({text:e.message,kind:`err`})}),N=r({mutationFn:e=>i.post(`/api/auth/verify-2fa`,{code:e}),onSuccess:e=>{_(`idle`),S(``),b(null),e.backupCodes&&e.backupCodes.length&&E(e.backupCodes),t.invalidateQueries({queryKey:[`auth-me`]}),t.invalidateQueries({queryKey:[`2fa-backup-count`]}),A({text:`2FA enabled`,kind:`ok`})},onError:e=>A({text:e.message||`Invalid code`,kind:`err`})}),P=r({mutationFn:e=>i.post(`/api/auth/disable-2fa`,{password:e}),onSuccess:()=>{_(`idle`),w(``),t.invalidateQueries({queryKey:[`auth-me`]}),t.invalidateQueries({queryKey:[`2fa-backup-count`]}),A({text:`2FA disabled`,kind:`info`})},onError:e=>A({text:e.message||`Failed`,kind:`err`})}),F=r({mutationFn:e=>i.post(`/api/auth/2fa/backup-codes`,{password:e}),onSuccess:e=>{O(!1),e.codes&&e.codes.length&&E(e.codes),t.invalidateQueries({queryKey:[`2fa-backup-count`]})},onError:e=>{O(!1),A({text:e.message||`Failed to regenerate codes`,kind:`err`})}});return(0,l.jsxs)(`section`,{className:u,"data-testid":`2fa-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Two-Factor Authentication`}),(0,l.jsxs)(`p`,{className:`text-sm`,"data-testid":`2fa-status`,children:[`Status:`,` `,(0,l.jsx)(`span`,{className:n?`text-green-600 font-medium`:`text-destructive font-medium`,children:n?`✅ Enabled`:`❌ Not enabled`})]}),!n&&g===`idle`&&(0,l.jsx)(`button`,{type:`button`,className:f,onClick:()=>M.mutate(),disabled:M.isPending,"data-testid":`btn-setup-2fa`,children:M.isPending?`Setting up…`:`Enable 2FA`}),n&&g===`idle`&&(0,l.jsxs)(`div`,{className:`flex items-center gap-3 flex-wrap`,children:[(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive`,onClick:()=>_(`disabling`),"data-testid":`btn-disable-2fa`,children:`Disable 2FA`}),j&&(0,l.jsxs)(`span`,{className:`text-sm `+(j.remaining<=2?`text-orange-500`:`text-muted-foreground`),children:[j.remaining,` backup codes remaining.`]}),j&&(0,l.jsx)(`button`,{type:`button`,className:p,onClick:()=>O(!0),"data-testid":`btn-regen-backup-codes`,children:`Regenerate`})]}),g===`setup`&&y&&(0,l.jsxs)(`div`,{className:`space-y-3 p-3 bg-muted/40 rounded-md`,children:[(0,l.jsx)(`p`,{className:`text-sm`,children:`Scan this QR code with your authenticator app:`}),(0,l.jsx)(`img`,{src:y.qrCode,alt:`2FA QR code`,className:`bg-white p-2 rounded max-w-[240px]`}),(0,l.jsxs)(`p`,{className:`text-sm`,children:[`Or enter manually: `,(0,l.jsx)(`code`,{className:`bg-muted px-2 py-0.5 rounded text-xs`,children:y.secret})]}),(0,l.jsxs)(`div`,{className:`flex items-center gap-2 flex-wrap`,children:[(0,l.jsx)(`input`,{type:`text`,maxLength:6,className:d+` max-w-[140px] font-mono tracking-widest`,placeholder:`123456`,value:x,onChange:e=>S(e.target.value.replace(/\D/g,``)),"data-testid":`2fa-verify-code`}),(0,l.jsx)(`button`,{type:`button`,className:f,disabled:x.length!==6||N.isPending,onClick:()=>N.mutate(x),"data-testid":`btn-verify-2fa`,children:N.isPending?`Verifying…`:`Verify & Enable`}),(0,l.jsx)(`button`,{type:`button`,className:p,onClick:()=>{_(`idle`),b(null),S(``)},children:`Cancel`})]})]}),g===`disabling`&&(0,l.jsxs)(`div`,{className:`space-y-2 p-3 bg-muted/40 rounded-md max-w-sm`,children:[(0,l.jsx)(`label`,{className:`block text-sm font-medium`,children:`Enter your password to confirm:`}),(0,l.jsx)(`input`,{type:`password`,className:d,value:C,onChange:e=>w(e.target.value),placeholder:`Password`,"data-testid":`2fa-disable-password`}),(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[(0,l.jsx)(`button`,{type:`button`,className:m,disabled:!C||P.isPending,onClick:()=>P.mutate(C),"data-testid":`btn-disable-2fa-confirm`,children:P.isPending?`Disabling…`:`Confirm Disable`}),(0,l.jsx)(`button`,{type:`button`,className:p,onClick:()=>{_(`idle`),w(``)},"data-testid":`btn-disable-2fa-cancel`,children:`Cancel`})]})]}),(0,l.jsx)(h,{msg:k}),(0,l.jsx)(s,{open:D,title:`Regenerate backup codes?`,body:`This invalidates your existing codes. Enter your current password to confirm.`,confirmText:`Regenerate`,danger:!0,requirePassword:!0,passwordPlaceholder:`Current password`,busy:F.isPending,onConfirm:e=>e&&F.mutate(e),onCancel:()=>O(!1)}),T&&(0,l.jsx)(v,{codes:T,onClose:()=>E(null)})]})}function b({s:e,isCurrent:t,onRevoke:n}){return(0,l.jsxs)(`div`,{className:`flex items-center justify-between gap-3 rounded-md border-2 px-3 py-2 `+(t?`border-primary bg-primary/5`:`border-border bg-muted/40`),"data-testid":`session-row-`+e.id,children:[(0,l.jsxs)(`div`,{className:`min-w-0`,children:[(0,l.jsxs)(`div`,{className:`text-sm font-medium truncate`,children:[e.device_label||`Unknown device`,t&&(0,l.jsx)(`span`,{className:`ml-2 text-xs text-primary font-medium`,children:`(this device)`})]}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[e.ip_address||`—`,` · Created `,new Date(e.created_at).toLocaleDateString(),` · Active `,g(e.last_activity)]})]}),!t&&(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:n,"data-testid":`btn-revoke-session-`+e.id,children:`Revoke`})]})}function x(){let e=o(),[t,n]=(0,c.useState)(null),[d,f]=(0,c.useState)(null),{data:m,isLoading:g,error:_}=a({queryKey:[`sessions`],queryFn:()=>i.get(`/api/sessions`)}),v=r({mutationFn:e=>i.delete(`/api/sessions/`+e),onSuccess:()=>{f({text:`Session revoked`,kind:`info`}),e.invalidateQueries({queryKey:[`sessions`]})},onError:e=>f({text:e.message,kind:`err`})}),y=r({mutationFn:()=>i.delete(`/api/sessions`),onSuccess:t=>{f({text:`All other sessions revoked (`+(t.revoked||0)+` removed)`,kind:`info`}),e.invalidateQueries({queryKey:[`sessions`]})},onError:e=>f({text:e.message,kind:`err`})}),x=v.isPending||y.isPending;return(0,l.jsxs)(`section`,{className:u,"data-testid":`sessions-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Active Sessions`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Devices where you are currently logged in. Revoke any session to immediately log that device out.`}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:()=>n({kind:`all`}),"data-testid":`btn-revoke-all-sessions`,children:`Revoke All Other Sessions`}),g&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading sessions…`}),_&&(0,l.jsx)(`div`,{className:`text-sm text-destructive`,children:`Could not load sessions.`}),(0,l.jsxs)(`div`,{className:`space-y-2`,children:[m?.sessions.map(e=>(0,l.jsx)(b,{s:e,isCurrent:e.id===m.currentSessionId,onRevoke:()=>n({kind:`one`,id:e.id})},e.id)),m&&m.sessions.length===0&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No active sessions found.`})]}),(0,l.jsx)(h,{msg:d}),(0,l.jsx)(s,{open:t?.kind===`one`,title:`Revoke this session?`,body:`That device will be logged out.`,confirmText:`Revoke`,danger:!0,busy:x,onConfirm:()=>{t?.kind===`one`&&v.mutate(t.id),n(null)},onCancel:()=>n(null)}),(0,l.jsx)(s,{open:t?.kind===`all`,title:`Revoke all other sessions?`,body:`All other devices will be logged out.`,confirmText:`Revoke All`,danger:!0,busy:x,onConfirm:()=>{y.mutate(),n(null)},onCancel:()=>n(null)})]})}function S({user:e}){let t=o(),n=!!e.nextcloud_url,[a,m]=(0,c.useState)(e.nextcloud_url||``),[g,_]=(0,c.useState)(e.nextcloud_user||``),[v,y]=(0,c.useState)(``),[b,x]=(0,c.useState)(e.webdav_learning_path||``),[S,C]=(0,c.useState)(!1),[w,T]=(0,c.useState)(null),E=r({mutationFn:e=>i.post(`/api/nextcloud/connect`,e),onSuccess:e=>{T({text:e.message||`Connected`,kind:`ok`}),y(``),t.invalidateQueries({queryKey:[`auth-me`]})},onError:e=>T({text:e.message||`Connection failed`,kind:`err`})}),D=r({mutationFn:()=>i.post(`/api/nextcloud/disconnect`,{}),onSuccess:()=>{T({text:`Disconnected`,kind:`info`}),y(``),t.invalidateQueries({queryKey:[`auth-me`]})},onError:e=>T({text:e.message,kind:`err`})}),O=r({mutationFn:e=>i.post(`/api/user/webdav-path`,{path:e}),onSuccess:()=>{T({text:`Path saved`,kind:`ok`}),t.invalidateQueries({queryKey:[`auth-me`]})},onError:e=>T({text:e.message||`Failed to save`,kind:`err`})});function k(e){e.preventDefault(),T(null);let t=a.trim().replace(/\/+$/,``),n=g.trim(),r=v.trim();if(!t||!n||!r)return T({text:`Fill all Nextcloud fields`,kind:`err`});E.mutate({nextcloudUrl:t,username:n,appPassword:r})}return(0,l.jsxs)(`section`,{className:u,"data-testid":`nextcloud-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Nextcloud Integration`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Export generated documents to your Nextcloud.`}),(0,l.jsx)(`div`,{className:`text-sm`,"data-testid":`nc-status`,children:n?(0,l.jsxs)(l.Fragment,{children:[`✅ Connected to `,(0,l.jsx)(`strong`,{children:e.nextcloud_url}),` as `,e.nextcloud_user]}):(0,l.jsx)(l.Fragment,{children:`Not connected`})}),(0,l.jsxs)(`form`,{onSubmit:k,className:`space-y-2 max-w-md`,children:[(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`Nextcloud URL`}),(0,l.jsx)(`input`,{type:`url`,className:d,value:a,onChange:e=>m(e.target.value),placeholder:`https://cloud.example.com`,"data-testid":`nc-url`})]}),(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`Username`}),(0,l.jsx)(`input`,{type:`text`,className:d,value:g,onChange:e=>_(e.target.value),placeholder:`your-username`,"data-testid":`nc-user`})]}),(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`App Password`}),(0,l.jsx)(`input`,{type:`password`,className:d,value:v,onChange:e=>y(e.target.value),placeholder:`Generate in Nextcloud → Settings → Security`,"data-testid":`nc-pass`}),(0,l.jsx)(`span`,{className:`block text-xs text-muted-foreground mt-1`,children:`Go to Nextcloud → Settings → Security → Create new app password`})]}),(0,l.jsxs)(`div`,{className:`flex gap-2 flex-wrap`,children:[(0,l.jsx)(`button`,{type:`submit`,className:f,disabled:E.isPending,"data-testid":`btn-nc-connect`,children:E.isPending?`Connecting…`:n?`Reconnect`:`Connect`}),n&&(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive`,onClick:()=>C(!0),"data-testid":`btn-nc-disconnect`,children:`Disconnect`})]})]}),n&&(0,l.jsx)(`div`,{className:`border-t border-border pt-3 space-y-2 max-w-md`,children:(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`Learning Hub — Default Browse Path`}),(0,l.jsxs)(`span`,{className:`block text-xs text-muted-foreground mb-2`,children:[`Folder opened first when picking files for AI content generation (e.g. `,(0,l.jsx)(`code`,{children:`/Medical-Resources`}),`)`]}),(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[(0,l.jsx)(`input`,{type:`text`,className:d,value:b,onChange:e=>x(e.target.value),placeholder:`/Medical-Resources`,"data-testid":`nc-webdav-path`}),(0,l.jsx)(`button`,{type:`button`,className:f,disabled:O.isPending,onClick:()=>O.mutate(b.trim()),"data-testid":`btn-nc-save-path`,children:O.isPending?`Saving…`:`Save Path`})]})]})}),(0,l.jsx)(h,{msg:w}),(0,l.jsx)(s,{open:S,title:`Disconnect Nextcloud?`,body:`Future exports will fail until you reconnect. Your stored credentials will be cleared.`,confirmText:`Disconnect`,danger:!0,busy:D.isPending,onConfirm:()=>{D.mutate(),C(!1)},onCancel:()=>C(!1)})]})}function C(e){return e<1024?e+` B`:e<1048576?Math.round(e/1024)+` KB`:(e/1048576).toFixed(1)+` MB`}function w({doc:e,onDownload:t,onDelete:n,downloading:r}){return(0,l.jsxs)(`div`,{className:`flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border`,"data-testid":`doc-row-`+e.id,children:[(0,l.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,l.jsx)(`div`,{className:`text-sm font-medium truncate`,children:e.filename}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[C(e.size_bytes),` · `,new Date(e.created_at).toLocaleDateString(),e.description?` · `+e.description:``]})]}),(0,l.jsx)(`button`,{type:`button`,className:f+` text-xs`,disabled:r,onClick:t,"data-testid":`btn-doc-download-`+e.id,children:r?`…`:`Download`}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:n,"data-testid":`btn-doc-delete-`+e.id,children:`Delete`})]})}function T(){let e=o(),[t,n]=(0,c.useState)(null),[p,m]=(0,c.useState)(``),[g,_]=(0,c.useState)(null),[v,y]=(0,c.useState)(null),{data:b,isLoading:x,error:S}=a({queryKey:[`documents`],queryFn:()=>i.get(`/api/documents`)}),C=r({mutationFn:async e=>{let t=new FormData;t.append(`file`,e.file),t.append(`description`,e.description);let n=await fetch(`/api/documents/upload`,{method:`POST`,credentials:`include`,body:t}),r=(n.headers.get(`content-type`)||``).includes(`application/json`)?await n.json():null;if(!n.ok||r&&r.success===!1)throw Error(r&&r.error||n.statusText);return r},onSuccess:t=>{_({text:`Document uploaded: `+t.filename,kind:`ok`}),n(null),m(``),e.invalidateQueries({queryKey:[`documents`]})},onError:e=>_({text:`Upload failed: `+e.message,kind:`err`})}),T=r({mutationFn:e=>i.delete(`/api/documents/`+e),onSuccess:()=>{_({text:`Document deleted`,kind:`info`}),e.invalidateQueries({queryKey:[`documents`]})},onError:e=>_({text:e.message||`Delete failed`,kind:`err`})}),E=r({mutationFn:e=>i.get(`/api/documents/`+e+`/download`),onSuccess:e=>{e.url?window.open(e.url,`_blank`,`noopener,noreferrer`):_({text:`Download failed`,kind:`err`})},onError:e=>_({text:e.message||`Download failed`,kind:`err`})});function D(e){if(e.preventDefault(),_(null),!t)return _({text:`Select a file first`,kind:`err`});C.mutate({file:t,description:p})}return(0,l.jsxs)(`section`,{className:u,"data-testid":`documents-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Documents`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.`}),x&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),S&&(0,l.jsx)(`div`,{className:`text-sm text-destructive`,children:`Failed to load documents.`}),b&&!b.s3_configured&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground italic`,children:`S3 storage not configured. Set S3_BUCKET in server environment.`}),b&&b.s3_configured&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsxs)(`form`,{onSubmit:D,className:`flex flex-wrap gap-2 items-center`,"data-testid":`doc-upload-area`,children:[(0,l.jsx)(`input`,{type:`file`,className:`text-sm`,accept:`.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.txt,.csv`,onChange:e=>n(e.target.files?.[0]??null),"data-testid":`doc-file-input`}),(0,l.jsx)(`input`,{type:`text`,className:d+` max-w-xs`,placeholder:`Description (optional)`,value:p,onChange:e=>m(e.target.value),"data-testid":`doc-description`}),(0,l.jsx)(`button`,{type:`submit`,className:f,disabled:!t||C.isPending,"data-testid":`btn-doc-upload`,children:C.isPending?`Uploading…`:`Upload`})]}),(0,l.jsx)(`div`,{className:`space-y-2`,children:b.documents.length===0?(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No documents uploaded yet.`}):b.documents.map(e=>(0,l.jsx)(w,{doc:e,onDownload:()=>E.mutate(e.id),onDelete:()=>y(e),downloading:E.isPending&&E.variables===e.id},e.id))})]}),(0,l.jsx)(h,{msg:g}),(0,l.jsx)(s,{open:!!v,title:`Delete this document permanently?`,body:v?.filename,confirmText:`Delete`,danger:!0,busy:T.isPending,onConfirm:()=>{v&&T.mutate(v.id),y(null)},onCancel:()=>y(null)})]})}function E(){let e=o(),[t,n]=(0,c.useState)(null),[s,m]=(0,c.useState)(``),[g,_]=(0,c.useState)(``),[v,y]=(0,c.useState)(!1),[b,x]=(0,c.useState)(!1),{data:S}=a({queryKey:[`voice-options`],queryFn:()=>i.get(`/api/user/preferences/options`)}),{data:C}=a({queryKey:[`voice-prefs`],queryFn:()=>i.get(`/api/user/preferences`)});!b&&C&&(m(C.stt_model||``),_(C.tts_voice||``),x(!0));let w=r({mutationFn:e=>i.post(`/api/user/preferences`,e),onSuccess:()=>{n({text:`Voice preferences saved`,kind:`ok`}),e.invalidateQueries({queryKey:[`voice-prefs`]})},onError:e=>n({text:e.message||`Save failed`,kind:`err`})});async function T(){n(null),y(!0);try{await i.post(`/api/user/preferences`,{tts_voice:g||null}),e.invalidateQueries({queryKey:[`voice-prefs`]});let t=`Hello, this is a preview of the `+(g||`server default`)+` voice. This is how your read-aloud feature will sound.`,n=await fetch(`/api/text-to-speech`,{method:`POST`,credentials:`include`,headers:{"Content-Type":`application/json`},body:JSON.stringify({text:t})});if(!n.ok)throw Error(`Preview failed`);let r=await n.blob(),a=URL.createObjectURL(r),o=new Audio(a);o.onended=()=>URL.revokeObjectURL(a),await o.play()}catch(e){n({text:`Preview failed: `+e.message,kind:`err`})}finally{y(!1)}}return(0,l.jsxs)(`section`,{className:u,"data-testid":`voice-preferences-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Voice Preferences`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.`}),(0,l.jsxs)(`div`,{className:`space-y-2 max-w-md`,children:[(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`Speech-to-Text Model (Transcription)`}),(0,l.jsxs)(`select`,{className:d,value:s,onChange:e=>m(e.target.value),"data-testid":`stt-model-select`,children:[(0,l.jsxs)(`option`,{value:``,children:[`Server default`,S?` (`+S.sttProvider+`)`:``]}),S?.sttModels.map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label},e.value))]})]}),(0,l.jsxs)(`label`,{className:`block text-sm`,children:[(0,l.jsx)(`span`,{className:`block text-xs font-semibold text-muted-foreground mb-1`,children:`Text-to-Speech Voice (Read Aloud)`}),(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[(0,l.jsxs)(`select`,{className:d,value:g,onChange:e=>_(e.target.value),"data-testid":`tts-voice-select`,children:[(0,l.jsxs)(`option`,{value:``,children:[`Server default`,S?` (`+S.ttsProvider+`)`:``]}),S?.ttsVoices.map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label},e.value))]}),(0,l.jsx)(`button`,{type:`button`,className:p,disabled:v,onClick:T,"data-testid":`btn-preview-voice`,children:v?`Loading…`:`Preview`})]})]}),(0,l.jsx)(`button`,{type:`button`,className:f,disabled:w.isPending,onClick:()=>w.mutate({stt_model:s||null,tts_voice:g||null}),"data-testid":`btn-save-voice-prefs`,children:w.isPending?`Saving…`:`Save Voice Preferences`})]}),(0,l.jsx)(h,{msg:t})]})}var D=`ped_browser_whisper_enabled`,O=`ped_browser_whisper_model`,k=[{value:`Xenova/whisper-tiny.en`,label:`Tiny (~39MB) — fastest, ~2-3s`},{value:`Xenova/whisper-base.en`,label:`Base (~74MB) — balanced, ~3-5s`},{value:`Xenova/whisper-small.en`,label:`Small (~244MB) — best quality, ~6-10s`}];function A(e,t=``){try{return window.localStorage.getItem(e)??t}catch{return t}}function j(e,t){try{window.localStorage.setItem(e,t)}catch{}}function M(){let[e,t]=(0,c.useState)(A(D)===`true`),[n,r]=(0,c.useState)(A(O)||k[0].value);function i(e){t(e),j(D,String(e))}function a(e){r(e),j(O,e)}return(0,l.jsxs)(`section`,{className:u,"data-testid":`browser-whisper-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Browser Transcription (Local Whisper)`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.`}),(0,l.jsxs)(`div`,{className:`flex items-center gap-3 flex-wrap`,children:[(0,l.jsx)(`label`,{className:`text-sm font-medium`,children:`Enable browser transcription:`}),(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:e,onChange:e=>i(e.target.checked),"data-testid":`browser-whisper-enabled`}),(0,l.jsx)(`span`,{className:`text-sm`,"data-testid":`browser-whisper-status`,children:e?`On — audio stays on device`:`Off`})]})]}),(0,l.jsxs)(`div`,{className:`flex items-center gap-3 flex-wrap`,children:[(0,l.jsx)(`label`,{className:`text-sm font-medium`,children:`Model:`}),(0,l.jsx)(`select`,{className:d+` max-w-md`,value:n,onChange:e=>a(e.target.value),"data-testid":`browser-whisper-model`,children:k.map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label},e.value))})]}),(0,l.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components — pre-download will light up when those port to React.`})]})}var N=`ped_web_speech_enabled`;function P(){let[e,t]=(0,c.useState)(A(N)===`true`),[n,r]=(0,c.useState)(!1),i=typeof window<`u`&&(`webkitSpeechRecognition`in window||`SpeechRecognition`in window);function a(){j(D,`false`),t(!0),j(N,`true`)}function o(){t(!1),j(N,`false`)}return(0,l.jsxs)(`section`,{className:u+` border-l-4 border-l-orange-500`,"data-testid":`web-speech-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Real-Time Streaming Transcription`}),(0,l.jsxs)(`div`,{className:`bg-orange-50 dark:bg-orange-950/30 p-3 rounded-md text-sm text-orange-900 dark:text-orange-100`,children:[(0,l.jsx)(`strong`,{children:`Privacy Warning:`}),` Uses your browser's built-in speech recognition, which `,(0,l.jsx)(`strong`,{children:`may send audio to cloud servers`}),` (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.`]}),(0,l.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`See words appear as you speak (streaming). Overrides browser and server transcription when enabled. `,(0,l.jsx)(`strong`,{children:`Not HIPAA-compliant`}),` in most browsers.`]}),(0,l.jsxs)(`div`,{className:`flex items-center gap-3 flex-wrap`,children:[(0,l.jsx)(`label`,{className:`text-sm font-medium`,children:`Enable real-time streaming:`}),(0,l.jsxs)(`label`,{className:`flex items-center gap-2 cursor-pointer`,children:[(0,l.jsx)(`input`,{type:`checkbox`,className:`accent-orange-500 size-4`,checked:e,disabled:!i,onChange:e=>{e.target.checked?r(!0):o()},"data-testid":`web-speech-enabled`}),(0,l.jsx)(`span`,{className:`text-sm`,"data-testid":`web-speech-status`,children:i?e?`On — real-time streaming`:`Off`:`Not supported in this browser`})]})]}),i&&(0,l.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Streaming integration lights up when the recording components port to React.`}),(0,l.jsx)(s,{open:n,title:`Enable real-time streaming transcription?`,body:`Your browser's speech recognition may send audio to cloud servers (e.g. Google). This is NOT HIPAA-compliant. Only enable if you understand and accept this privacy trade-off.`,confirmText:`Enable`,danger:!0,onConfirm:()=>{a(),r(!1)},onCancel:()=>r(!1)})]})}var F=[{value:`physical_exam`,label:`Physical Exam Template`},{value:`ros`,label:`Review of Systems Template`},{value:`encounter_format`,label:`Encounter Note Format`},{value:`family_history`,label:`Family History Format`},{value:`assessment_plan`,label:`Assessment & Plan Format`},{value:`template_soap`,label:`SOAP Note Template`},{value:`template_hpi`,label:`HPI Template`},{value:`template_wellvisit`,label:`Well Visit Template`},{value:`template_sickvisit`,label:`Sick Visit Template`},{value:`custom`,label:`Custom`}],I=Object.fromEntries(F.map(e=>[e.value,e.label.replace(/ Template$| Format$/,``)]));function L(){let e=o(),[t,n]=(0,c.useState)(null),[m,g]=(0,c.useState)(null),[_,v]=(0,c.useState)(F[0].value),[y,b]=(0,c.useState)(``),[x,S]=(0,c.useState)(``),[C,w]=(0,c.useState)(null),{data:T}=a({queryKey:[`memories`],queryFn:()=>i.get(`/api/memories`)}),E=(T?.memories||[]).filter(e=>!e.category.startsWith(`correction_`)),D=r({mutationFn:e=>{let{id:t,...n}=e;return t?i.put(`/api/memories/`+t,n):i.post(`/api/memories`,n)},onSuccess:()=>{n({text:m?`Template updated`:`Template saved`,kind:`ok`}),g(null),b(``),S(``),e.invalidateQueries({queryKey:[`memories`]})},onError:e=>n({text:e.message||`Save failed`,kind:`err`})}),O=r({mutationFn:e=>i.delete(`/api/memories/`+e),onSuccess:()=>{n({text:`Template deleted`,kind:`info`}),e.invalidateQueries({queryKey:[`memories`]})},onError:e=>n({text:e.message||`Delete failed`,kind:`err`})});function k(e){g(e.id),v(e.category),b(e.name),S(e.content)}function A(){g(null),b(``),S(``)}function j(e){if(e.preventDefault(),n(null),!y.trim())return n({text:`Enter a template name`,kind:`err`});if(!x.trim())return n({text:`Enter template content`,kind:`err`});D.mutate({id:m??void 0,category:_,name:y.trim(),content:x.trim()})}return(0,l.jsxs)(`section`,{className:u,"data-testid":`templates-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`My Templates`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.`}),(0,l.jsxs)(`form`,{onSubmit:j,className:`space-y-2`,children:[(0,l.jsxs)(`div`,{className:`flex gap-2 flex-wrap items-center`,children:[(0,l.jsx)(`select`,{className:d+` max-w-xs`,value:_,onChange:e=>v(e.target.value),"data-testid":`mem-category`,children:F.map(e=>(0,l.jsx)(`option`,{value:e.value,children:e.label},e.value))}),(0,l.jsx)(`input`,{type:`text`,className:d+` flex-1 min-w-[150px]`,placeholder:`Template name (e.g. Normal PE)`,value:y,onChange:e=>b(e.target.value),"data-testid":`mem-name`})]}),(0,l.jsx)(`textarea`,{rows:5,className:d+` resize-y`,placeholder:`Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL…`,value:x,onChange:e=>S(e.target.value),"data-testid":`mem-content`}),(0,l.jsxs)(`div`,{className:`flex gap-2`,children:[(0,l.jsx)(`button`,{type:`submit`,className:f,disabled:D.isPending,"data-testid":`btn-mem-save`,children:D.isPending?`Saving…`:m?`Update Template`:`Add Template`}),m!==null&&(0,l.jsx)(`button`,{type:`button`,className:p,onClick:A,children:`Cancel`})]})]}),(0,l.jsx)(`div`,{className:`space-y-2`,children:E.length===0?(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No templates saved yet. Add one above.`}):E.map(e=>(0,l.jsxs)(`div`,{className:`flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border`,"data-testid":`mem-row-`+e.id,children:[(0,l.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,l.jsxs)(`div`,{className:`flex items-center gap-2 text-sm`,children:[(0,l.jsx)(`span`,{className:`font-medium`,children:e.name}),(0,l.jsx)(`span`,{className:`text-xs text-muted-foreground uppercase tracking-wide`,children:I[e.category]||e.category})]}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground truncate`,children:[(e.content||``).slice(0,100).replace(/\n/g,` `),e.content&&e.content.length>100?`…`:``]})]}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-xs`,onClick:()=>k(e),"data-testid":`btn-mem-edit-`+e.id,children:`Edit`}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:()=>w(e),"data-testid":`btn-mem-delete-`+e.id,children:`Delete`})]},e.id))}),(0,l.jsx)(h,{msg:t}),(0,l.jsx)(s,{open:!!C,title:`Delete template "`+(C?.name||``)+`"?`,body:`This cannot be undone.`,confirmText:`Delete`,danger:!0,busy:O.isPending,onConfirm:()=>{C&&O.mutate(C.id),w(null)},onCancel:()=>w(null)})]})}var R={correction_soap:`SOAP Correction`,correction_hpi:`HPI Correction`,correction_encounter:`Encounter Correction`,correction_wellvisit:`Well Visit Correction`,correction_sickvisit:`Sick Visit Correction`};function z(e){let t=e.indexOf(`
|
|
CORRECTED TO: `);return t===-1?{original:e.trim(),corrected:``}:{original:e.substring(0,t).replace(/^ORIGINAL:\s*/i,``).trim(),corrected:e.substring(t+15).trim()}}function B(){let e=o(),[t,n]=(0,c.useState)({}),[d,f]=(0,c.useState)(null),[p,m]=(0,c.useState)(null),{data:g}=a({queryKey:[`memories`],queryFn:()=>i.get(`/api/memories`)}),_=(g?.memories||[]).filter(e=>e.category.startsWith(`correction_`)),v=r({mutationFn:e=>i.delete(`/api/memories/`+e),onSuccess:()=>{f({text:`Correction deleted`,kind:`info`}),e.invalidateQueries({queryKey:[`memories`]})},onError:e=>f({text:e.message||`Delete failed`,kind:`err`})});return(0,l.jsxs)(`section`,{className:u,"data-testid":`corrections-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`AI Learning (Corrections)`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.`}),(0,l.jsx)(`div`,{className:`space-y-2`,children:_.length===0?(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No corrections yet. Edit AI-generated notes and save to start learning.`}):_.map(e=>{let r=t[e.id],i=z(e.content||``),a=e.created_at?new Date(e.created_at).toLocaleDateString():``;return(0,l.jsxs)(`div`,{className:`rounded-md bg-muted/40 border border-border overflow-hidden`,"data-testid":`corr-row-`+e.id,children:[(0,l.jsxs)(`button`,{type:`button`,className:`w-full flex items-center gap-2 px-3 py-2 text-left`,onClick:()=>n({...t,[e.id]:!r}),children:[(0,l.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:r?`▾`:`▸`}),(0,l.jsx)(`span`,{className:`text-xs text-muted-foreground uppercase tracking-wide`,children:R[e.category]||e.category}),(0,l.jsx)(`span`,{className:`flex-1 text-sm truncate`,children:e.name}),(0,l.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:a}),(0,l.jsx)(`span`,{role:`button`,className:`text-destructive text-xs px-2`,onClick:t=>{t.stopPropagation(),m(e)},"data-testid":`btn-corr-delete-`+e.id,children:`Delete`})]}),r&&(0,l.jsxs)(`div`,{className:`px-3 py-2 text-sm space-y-2 border-t border-border`,children:[(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`div`,{className:`text-xs font-semibold uppercase text-destructive mb-1`,children:`Original (AI generated):`}),(0,l.jsx)(`div`,{className:`whitespace-pre-wrap text-muted-foreground`,children:i.original})]}),i.corrected&&(0,l.jsxs)(`div`,{children:[(0,l.jsx)(`div`,{className:`text-xs font-semibold uppercase text-green-600 mb-1`,children:`Corrected to:`}),(0,l.jsx)(`div`,{className:`whitespace-pre-wrap`,children:i.corrected})]})]})]},e.id)})}),(0,l.jsx)(h,{msg:d}),(0,l.jsx)(s,{open:!!p,title:`Delete this correction?`,body:`The AI will no longer apply this edit in future notes.`,confirmText:`Delete`,danger:!0,busy:v.isPending,onConfirm:()=>{p&&v.mutate(p.id),m(null)},onCancel:()=>m(null)})]})}function V(){let e=o(),[t,n]=(0,c.useState)(null),[d,m]=(0,c.useState)(null),{data:_,isLoading:v}=a({queryKey:[`audio-backups`],queryFn:()=>i.get(`/api/audio-backups`)}),y=r({mutationFn:e=>i.delete(`/api/audio-backups/`+e),onSuccess:()=>{n({text:`Backup deleted`,kind:`info`}),e.invalidateQueries({queryKey:[`audio-backups`]})},onError:e=>n({text:e.message,kind:`err`})});function b(e){window.open(`/api/audio-backups/`+e+`/audio`,`_blank`,`noopener,noreferrer`)}return(0,l.jsxs)(`section`,{className:u,"data-testid":`audio-backups-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Audio Backups`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.`}),v&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),(0,l.jsxs)(`div`,{className:`space-y-2`,children:[_&&_.backups.length===0&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No audio backups.`}),_?.backups.map(e=>{let t=Math.round(e.size_bytes/1024),n=e.compressed_bytes?` (`+Math.round(e.compressed_bytes/1024)+` KB compressed)`:``;return(0,l.jsxs)(`div`,{className:`flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border`,"data-testid":`audio-backup-row-`+e.id,children:[(0,l.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,l.jsxs)(`div`,{className:`text-sm font-medium`,children:[e.module,` recording`]}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground`,children:[new Date(e.created_at).toLocaleString(),` · `,t,` KB`,n,` · `,g(e.created_at)]})]}),(0,l.jsx)(`button`,{type:`button`,className:f+` text-xs`,onClick:()=>b(e.id),children:`Play`}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:()=>m(e),"data-testid":`btn-audio-delete-`+e.id,children:`Delete`})]},e.id)})]}),(0,l.jsx)(h,{msg:t}),(0,l.jsx)(s,{open:!!d,title:`Delete this audio backup?`,body:`This cannot be undone.`,confirmText:`Delete`,danger:!0,busy:y.isPending,onConfirm:()=>{d&&y.mutate(d.id),m(null)},onCancel:()=>m(null)})]})}function H(){let e=o(),[t,n]=(0,c.useState)(null),[d,f]=(0,c.useState)(null),{data:m,isLoading:g}=a({queryKey:[`saved-encounters`],queryFn:()=>i.get(`/api/encounters/saved`)}),_=r({mutationFn:e=>i.delete(`/api/encounters/saved/`+e),onSuccess:()=>{n({text:`Deleted`,kind:`info`}),e.invalidateQueries({queryKey:[`saved-encounters`]})},onError:e=>n({text:e.message,kind:`err`})});return(0,l.jsxs)(`section`,{className:u,"data-testid":`saved-encounters-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Saved Encounters`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Encounters are automatically deleted after 7 days per site policy. Resume action ports with the encounter components.`}),g&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),(0,l.jsxs)(`div`,{className:`space-y-2`,children:[m&&m.encounters.length===0&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No saved encounters.`}),m?.encounters.map(e=>{let t=new Date(e.updated_at).toLocaleDateString(),n=new Date(e.expires_at).toLocaleDateString(),r=(e.transcript_preview||``).slice(0,80);return(0,l.jsxs)(`div`,{className:`flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border`,"data-testid":`enc-row-`+e.id,children:[(0,l.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,l.jsxs)(`div`,{className:`flex items-center gap-2 text-sm`,children:[(0,l.jsx)(`span`,{className:`font-medium truncate`,children:e.label||`Untitled`}),(0,l.jsx)(`span`,{className:`text-xs text-muted-foreground uppercase tracking-wide`,children:e.enc_type})]}),(0,l.jsxs)(`div`,{className:`text-xs text-muted-foreground truncate`,children:[t,` · expires `,n,r?` · `+r+`…`:``]})]}),(0,l.jsx)(`button`,{type:`button`,className:p+` text-destructive text-xs`,onClick:()=>f(e),"data-testid":`btn-enc-delete-`+e.id,children:`Delete`})]},e.id)})]}),(0,l.jsx)(h,{msg:t}),(0,l.jsx)(s,{open:!!d,title:`Delete this saved encounter?`,body:d?.label||void 0,confirmText:`Delete`,danger:!0,busy:_.isPending,onConfirm:()=>{d&&_.mutate(d.id),f(null)},onCancel:()=>f(null)})]})}function U(){return(0,l.jsxs)(`section`,{className:u,"data-testid":`compliance-section`,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Compliance & Usage`}),(0,l.jsxs)(`div`,{className:`text-sm space-y-2`,children:[(0,l.jsxs)(`p`,{children:[(0,l.jsx)(`strong`,{children:`AWS Bedrock`}),` is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.`]}),(0,l.jsxs)(`ul`,{className:`list-disc pl-5 space-y-1 text-muted-foreground`,children:[(0,l.jsx)(`li`,{children:`All connections use HTTPS/TLS encryption`}),(0,l.jsx)(`li`,{children:`Authentication with optional 2FA`}),(0,l.jsx)(`li`,{children:`No patient data stored on server beyond session`}),(0,l.jsx)(`li`,{children:`AWS Bedrock supports BAA for HIPAA compliance`}),(0,l.jsx)(`li`,{children:`Azure OpenAI supports BAA for HIPAA compliance`})]}),(0,l.jsxs)(`p`,{children:[(0,l.jsx)(`strong`,{children:`Important:`}),` Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.`]})]})]})}function W(){let{data:e,isLoading:t,error:n}=a({queryKey:[`auth-me`],queryFn:()=>i.get(`/api/auth/me`)}),r=e?.user.canLocalAuth!==!1;return(0,l.jsxs)(`div`,{className:`max-w-3xl mx-auto p-6 space-y-4`,children:[(0,l.jsxs)(`header`,{children:[(0,l.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Settings`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Account security, integrations, and personal templates. More sub-sections port over in follow-up commits.`})]}),t&&(0,l.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`}),n&&(0,l.jsxs)(`div`,{className:`text-sm text-destructive`,children:[`Could not load your account: `,n.message]}),e&&r&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(_,{}),(0,l.jsx)(y,{user:e.user}),(0,l.jsx)(x,{})]}),e&&!r&&(0,l.jsxs)(`section`,{className:u,children:[(0,l.jsx)(`h3`,{className:`text-base font-semibold`,children:`Account managed by single sign-on`}),(0,l.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Your password and two-factor authentication are managed by your identity provider.`})]}),e&&(0,l.jsx)(S,{user:e.user}),e&&(0,l.jsx)(T,{}),e&&(0,l.jsx)(E,{}),e&&(0,l.jsx)(M,{}),e&&(0,l.jsx)(P,{}),e&&(0,l.jsx)(L,{}),e&&(0,l.jsx)(B,{}),e&&(0,l.jsx)(V,{}),e&&(0,l.jsx)(H,{}),(0,l.jsx)(U,{})]})}export{W as default}; |