diff --git a/public/css/styles.css b/public/css/styles.css index 3873a5ad..7a132bc5 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1414,6 +1414,9 @@ body.assistant-preview .assistant-preview-note { display:flex; } /* Collapsed menus hide their contents; the card goes with them. */ /* A preview visitor has no account to show. */ body.assistant-preview .account-card { display:none; } +/* The sign-in prompt a preview visitor meets is an invitation, not the + compliance notice a clinician sees on first login. */ +body.assistant-preview .hipaa-notice { display:none; } /* ── Search palette ────────────────────────────────────────────────────────── One component in both views; the view decides what it searches. */ diff --git a/public/js/auth.js b/public/js/auth.js index 55ac1362..6fec67f0 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -180,19 +180,23 @@ document.addEventListener('DOMContentLoaded', function() { // assistant instead of meeting a wall. Everything else still needs an // account, and the server enforces that independently — this only decides // whether to show the login screen. - if (window.location && window.location.pathname === '/assistant') { - fetch('/api/clinical-assistant/status', { credentials: 'same-origin' }) - .then(function(r) { return r.ok ? r.json() : null; }) - .then(function(data) { - if (data && data.success && data.preview) return enterPreview(); - authScreen.style.display = 'flex'; - }) - .catch(function() { authScreen.style.display = 'flex'; }); - return; - } - authScreen.style.display = 'flex'; + // Any path, not only /assistant: with preview on, a visitor landing on the + // root should meet the assistant, not a wall. enterPreview moves them there. + fetch('/api/clinical-assistant/status', { credentials: 'same-origin' }) + .then(function(r) { return r.ok ? r.json() : null; }) + .then(function(data) { + if (data && data.success && data.preview) return enterPreview(); + authScreen.style.display = 'flex'; + }) + .catch(function() { authScreen.style.display = 'flex'; }); } + // Raised by authFetch when a preview visitor reaches for something that needs + // an account. The screen stays an overlay, so their chat is still underneath. + document.addEventListener('preview-needs-account', function() { + if (authScreen && document.body.classList.contains('assistant-preview')) authScreen.style.display = 'flex'; + }); + // The auth screen is an overlay over an already-rendered app, so entering // preview is simply declining to raise it. function enterPreview() { diff --git a/public/js/authFetch.js b/public/js/authFetch.js index 4c220b8a..7c947532 100644 --- a/public/js/authFetch.js +++ b/public/js/authFetch.js @@ -9,6 +9,15 @@ if (!window.__fetchAuthIntercepted) { '/api/auth/verify-email', '/api/auth/resend-verification', '/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status' ]); + // The signed-out preview. Same four paths the server allow-lists; nothing + // else may leave the browser without an account. Until these were here the + // status call was rejected before it was ever sent, so the server-side + // preview could never be reached from the page. + var previewPaths = new Set([ + '/api/clinical-assistant/status', '/api/clinical-assistant/examples', + '/api/clinical-assistant/chat', '/api/clinical-assistant/chat/stream' + ]); + function inPreview() { return document.body && document.body.classList.contains('assistant-preview'); } // Narrow transition requests bypass the frozen clinical transport. Logout // headers are captured before freezing; its verification is cookie-only. @@ -40,6 +49,14 @@ if (!window.__fetchAuthIntercepted) { if (boundary.blocked()) return Promise.reject(boundary.error()); // No clinical module may preload a previous cookie's data on the login screen. if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') { + if (previewPaths.has(url.pathname)) return rawFetch(input, Object.assign({}, init, { credentials: 'same-origin' })); + // A preview visitor reaching for anything else is exactly the moment to + // ask them to sign in — one hook here, not a check on every button. Only + // for something the visitor did, though: the page also fetches saved + // chats and config in the background on load, and raising the sign-in + // screen for those buried the assistant under it before a word was typed. + var gesture = navigator.userActivation ? navigator.userActivation.isActive : false; + if (inPreview() && gesture) document.dispatchEvent(new CustomEvent('preview-needs-account', { detail: { path: url.pathname } })); return Promise.reject(boundary.error()); } var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register') diff --git a/test/assistant-preview.test.js b/test/assistant-preview.test.js index b19b8fa8..a6befa97 100644 --- a/test/assistant-preview.test.js +++ b/test/assistant-preview.test.js @@ -70,7 +70,9 @@ test('a preview visitor has no identity, so nothing can be owned or billed', () test('preview is entered by declining the login overlay, not by faking a session', () => { const auth = read('public/js/auth.js'); const fn = auth.slice(auth.indexOf('function showAuthScreen()'), auth.indexOf('// ── Check for SSO redirect')); - assert.match(fn, /pathname === '\/assistant'/, 'only the assistant page previews'); + // Any path previews, not only /assistant: a visitor landing on the root should + // meet the assistant, not a wall. enterPreview moves them onto that tab. + assert.doesNotMatch(fn, /pathname === '\/assistant'/, 'the root previews too'); assert.match(fn, /data\.success && data\.preview/, 'and only when the server says so'); assert.match(fn, /authScreen\.style\.display = 'flex';/, 'anything else raises the login screen'); // No token, no user object, no stored credential — the overlay is simply not @@ -90,4 +92,28 @@ test('reaching for the workspace is where a preview visitor is asked to sign in' assert.ok(css.includes('body.assistant-preview ' + hidden), hidden + ' is hidden in preview'); } assert.match(read('public/components/assistant.html'), /assistant-preview-note/, 'and the state is stated plainly'); + // The prompt a preview visitor meets is an invitation, not the compliance notice. + assert.ok(css.includes('body.assistant-preview .hipaa-notice { display:none; }'), 'no HIPAA notice in preview'); +}); + +test('the browser lets exactly the preview endpoints out without an account, and nothing else', () => { + // Every /api fetch without an account is rejected before it is sent. Until the + // preview paths were allowed here, the status call never reached the server, + // so the server-side preview was unreachable from the page. + const af = read('public/js/authFetch.js'); + const list = af.slice(af.indexOf('var previewPaths'), af.indexOf('function inPreview')); + for (const p of ['/api/clinical-assistant/status', '/api/clinical-assistant/examples', + '/api/clinical-assistant/chat', '/api/clinical-assistant/chat/stream']) { + assert.ok(list.includes("'" + p + "'"), p + ' is allowed'); + } + assert.doesNotMatch(list, /saved-chats|\/config|\/images|\/admin/, 'nothing that needs an account'); + assert.match(af, /if \(previewPaths\.has\(url\.pathname\)\) return rawFetch/, 'they go out with no ticket'); + // Anything else a preview visitor reaches for raises sign-in via one hook, + // rather than a check on every button. + // ...and only for something the visitor did. The page fetches saved chats and + // config in the background on load; raising sign-in for those buried the + // assistant before a word was typed. + assert.match(af, /navigator\.userActivation \? navigator\.userActivation\.isActive : false/); + assert.match(af, /if \(inPreview\(\) && gesture\) document\.dispatchEvent\(new CustomEvent\('preview-needs-account'/); + assert.match(read('public/js/auth.js'), /addEventListener\('preview-needs-account'/, 'and auth.js raises the screen'); });