diff --git a/web/app.js b/web/app.js index 81d8bc6..e280496 100644 --- a/web/app.js +++ b/web/app.js @@ -38,6 +38,15 @@ import { LoginPage } from './components/login-page.js'; import './lib/open-file.js'; import './lib/open-tool.js'; import { initI18n } from './lib/i18n.js'; +import { installSessionExpiryWatch } from './lib/session-expiry.js'; +import { installSessionRelogin } from './components/session-relogin.js'; + +// Installed before anything can call the API, so no component's first fetch +// escapes the 401 watch. A session that dies under an open tab is renewed in a +// modal over the page, leaving the app's state — an unsent message included — +// exactly where it was. +installSessionExpiryWatch(); +installSessionRelogin(); customElements.define('app-topbar', AppTopbar); customElements.define('app-sidebar', AppSidebar); diff --git a/web/components/login-page.js b/web/components/login-page.js index 6f0bc3f..ec4a4be 100644 --- a/web/components/login-page.js +++ b/web/components/login-page.js @@ -50,6 +50,9 @@ export class LoginPage extends I18nMixin(LightElement) { this._error = t('login.error'); return; } + // Remembered only to prefill the re-login dialog when this browser's + // session dies under an open tab — never a credential, just the name. + try { localStorage.setItem('skald.last_user', this._username.trim()); } catch { /* private mode */ } // Logged in — reload into the app. window.location.reload(); } catch { diff --git a/web/components/mobile-app.js b/web/components/mobile-app.js index 26b3022..cec20c5 100644 --- a/web/components/mobile-app.js +++ b/web/components/mobile-app.js @@ -1,6 +1,8 @@ import { LitElement, html, nothing } from 'lit'; import { t } from '../lib/i18n.js'; import { LoginPage } from './login-page.js'; +import { installSessionExpiryWatch } from '../lib/session-expiry.js'; +import { installSessionRelogin } from './session-relogin.js'; import './shared/inbox-page.js'; import './shared/chat-page.js'; import './shared/projects-page.js'; @@ -10,6 +12,12 @@ import './shared/tool-detail-mobile.js'; customElements.define('login-page', LoginPage); +// A session lost mid-use is renewed in place, over whatever section is open — +// same seam as the desktop shell. Both are no-ops in the native shell, which +// authenticates on its own. +installSessionExpiryWatch(); +installSessionRelogin(); + // Sections addressable via the URL hash — same routing style as the desktop // sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers // share this router so the URL always reflects the active section: native menu diff --git a/web/components/session-relogin.js b/web/components/session-relogin.js new file mode 100644 index 0000000..4cc1ae9 --- /dev/null +++ b/web/components/session-relogin.js @@ -0,0 +1,160 @@ +import { html, nothing } from 'lit'; +import { LightElement } from '../lib/base.js'; +import { t, I18nMixin } from '../lib/i18n.js'; +import { notifySessionRestored } from '../lib/session-expiry.js'; + +/** + * Re-login dialog for a session that died under an open tab. + * + * Sessions live in the server's RAM (blueprint §9), so a restart logs everyone + * out while their browser keeps sending a cookie nobody recognises. The obvious + * answer — bounce to the login screen — throws away everything the page was + * holding, and the composer's half-written message with it. So the session is + * renewed **in place**: a modal over the page the user was already on, one + * password field, and on success the app carries on with its state intact (the + * chat reconnects on `auth-restored`). + * + * Not dismissible, deliberately: with no session nothing on the page works, and + * a dialog you can wave away would leave a UI that silently fails every action. + */ +export class SessionRelogin extends I18nMixin(LightElement) { + + static get properties() { + return { + _open: { state: true }, + _username: { state: true }, + _password: { state: true }, + _error: { state: true }, + _busy: { state: true }, + }; + } + + constructor() { + super(); + this._open = false; + this._username = ''; + this._password = ''; + this._error = null; + this._busy = false; + } + + /** Raise the dialog, prefilling the last username known to this browser. */ + open() { + if (this._open) return; + let last = ''; + try { last = localStorage.getItem('skald.last_user') ?? ''; } catch { /* private mode */ } + this._username = last; + this._password = ''; + this._error = null; + this._open = true; + // Focus the field the user actually has to fill: the password when we + // already know who they are, the username otherwise. + this.updateComplete.then(() => { + const sel = last ? 'input[type="password"]' : 'input[type="text"]'; + this.querySelector(sel)?.focus(); + }); + } + + _submit(e) { + e.preventDefault(); + if (this._busy) return; + this._error = null; + if (!this._username.trim() || !this._password) { + this._error = t('login.missing'); + return; + } + this._doLogin(); + } + + async _doLogin() { + this._busy = true; + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this._username.trim(), + password: this._password, + }), + }); + if (!res.ok) { + this._error = t('login.error'); + return; + } + try { localStorage.setItem('skald.last_user', this._username.trim()); } catch { /* private mode */ } + this._password = ''; + this._open = false; + // The new cookie is live: tell the app to pick its connections back up. + notifySessionRestored(); + } catch { + this._error = t('login.network'); + } finally { + this._busy = false; + } + } + + render() { + if (!this._open) return nothing; + const btnLabel = this._busy + ? html`${t('login.signing')}` + : t('login.submit'); + + return html` +
+
+ + + + ${this._error ? html`` : null} + +
+ + this._username = e.target.value} + ?disabled=${this._busy} /> +
+ +
+ + this._password = e.target.value} + ?disabled=${this._busy} /> +
+ + +
+
+ `; + } +} + +/** + * Mount the dialog and wire it to `auth-expired`. + * + * The element appends itself to `` rather than living in the two shells' + * HTML: it belongs to whichever page happens to be open, and desktop and mobile + * would otherwise each have to remember to declare it. + */ +export function installSessionRelogin() { + if (!customElements.get('session-relogin')) { + customElements.define('session-relogin', SessionRelogin); + } + let el = null; + window.addEventListener('auth-expired', () => { + if (!el) { + el = document.createElement('session-relogin'); + document.body.appendChild(el); + } + el.open(); + }); +} diff --git a/web/css/setup-page.css b/web/css/setup-page.css index d26fac6..6f14123 100644 --- a/web/css/setup-page.css +++ b/web/css/setup-page.css @@ -105,3 +105,41 @@ .login-spinner { animation-name: setup-spin; } + +/* ── Re-login dialog ────────────────────────────────────────────────────────── + Raised over the page the user is already on when their session dies (see + components/session-relogin.js). Deliberately a scrim rather than an opaque + screen: seeing the work still there is the whole point of not navigating + away. Reuses `.login-card` so the two login surfaces cannot drift apart. */ + +.relogin-backdrop { + position: fixed; + inset: 0; + z-index: 10000; /* above the login screen's own layer */ + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(0, 0, 0, .5); + backdrop-filter: blur(2px); + overflow: auto; +} + +.relogin-card { + max-width: 400px; + padding: 28px 28px 24px; +} + +.relogin-title { + font-size: 1.15rem; + margin-bottom: 10px; +} + +@media (prefers-reduced-motion: no-preference) { + .relogin-card { animation: relogin-in .18s ease-out; } +} + +@keyframes relogin-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} diff --git a/web/i18n/en.js b/web/i18n/en.js index 7535201..bac3c8a 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -79,6 +79,7 @@ export default { 'chat.rejected': 'Denied.', 'chat.rejected_by_user': 'Denied by user.', 'chat.truncated': 'Response truncated by the token limit (↓{tokens} tok).', + 'chat.not_connected': 'Not connected — reconnecting. Your message is still in the box: press Enter again in a moment.', // ── Copilot render ───────────────────────────────────────────────────────── 'copilot.open_in_viewer': 'Open in viewer', @@ -307,6 +308,8 @@ export default { // ── Login ────────────────────────────────────────────────────────────────── 'login.title': 'Welcome back', 'login.subtitle': 'Sign in to your account.', + 'login.expired.title': 'Session expired', + 'login.expired': 'The server was restarted, so your session ended. Sign in again to carry on — the page keeps everything you had open, including any unsent message.', 'login.username': 'Username', 'login.password': 'Password', 'login.submit': 'Sign in', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 3603244..43f9f5c 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -79,6 +79,7 @@ export default { 'chat.rejected': 'Refusé.', 'chat.rejected_by_user': 'Refusé par l\'utilisateur.', 'chat.truncated': 'Réponse tronquée par la limite de tokens (↓{tokens} tok).', + 'chat.not_connected': 'Non connecté — reconnexion en cours. Votre message est resté dans le champ : appuyez de nouveau sur Entrée dans un instant.', // ── Copilot render ───────────────────────────────────────────────────────── 'copilot.open_in_viewer': 'Ouvrir dans le visualiseur', @@ -307,6 +308,8 @@ export default { // ── Login ────────────────────────────────────────────────────────────────── 'login.title': 'Bon retour', 'login.subtitle': 'Connectez-vous à votre compte.', + 'login.expired.title': 'Session expirée', + 'login.expired': 'Le serveur a redémarré, votre session a donc pris fin. Reconnectez-vous pour continuer : la page reste telle quelle, message non envoyé compris.', 'login.username': 'Nom d\'utilisateur', 'login.password': 'Mot de passe', 'login.submit': 'Se connecter', diff --git a/web/i18n/it.js b/web/i18n/it.js index 46456a7..1944834 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -79,6 +79,7 @@ export default { 'chat.rejected': 'Negata.', 'chat.rejected_by_user': 'Negata dall\'utente.', 'chat.truncated': 'Risposta troncata dal limite di token (↓{tokens} tok).', + 'chat.not_connected': 'Non connesso — riconnessione in corso. Il messaggio è rimasto nella casella: premi di nuovo Invio tra un istante.', // ── Copilot render ───────────────────────────────────────────────────────── 'copilot.open_in_viewer': 'Apri nel visualizzatore', @@ -307,6 +308,8 @@ export default { // ── Accesso ──────────────────────────────────────────────────────────────── 'login.title': 'Bentornato', 'login.subtitle': 'Accedi al tuo account.', + 'login.expired.title': 'Sessione scaduta', + 'login.expired': 'Il server è stato riavviato e la sessione è terminata. Accedi di nuovo per proseguire: la pagina resta com\'era, messaggio non inviato compreso.', 'login.username': 'Nome utente', 'login.password': 'Password', 'login.submit': 'Accedi', diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index ceb4052..a597cd7 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -1,6 +1,7 @@ import { html, nothing } from 'lit'; import { LightElement } from './base.js'; import { t } from './i18n.js'; +import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js'; // Slash commands handled entirely server-side: they reply with a `Done` and never // echo back as a `user_message`, so they are the only commands rendered @@ -109,10 +110,12 @@ export class ChatSession extends LightElement { // Each entry: { name, path, mimetype, filesize, uploading? }. While an upload // is in flight the entry has `uploading: true` and no `path` yet. this._attachments = []; + this._onAuthRestored = this._onAuthRestored.bind(this); } async connectedCallback() { super.connectedCallback(); + window.addEventListener('auth-restored', this._onAuthRestored); // Fire-and-forget: availability of a transcription provider determines // whether the mic button is rendered at all. this._checkTranscribe(); @@ -120,6 +123,11 @@ export class ChatSession extends LightElement { this._connectWS(); } + disconnectedCallback() { + super.disconnectedCallback?.(); + window.removeEventListener('auth-restored', this._onAuthRestored); + } + // ── Source identity — override in subclass ──────────────────────────────────── // Static default source for this component. Subclasses override (e.g. 'mobile'). @@ -223,7 +231,38 @@ export class ChatSession extends LightElement { } }; ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data)); - ws.onclose = () => { this._reconnecting = true; setTimeout(() => this._connectWS(), 2000); }; + ws.onclose = () => { this._reconnecting = true; this._scheduleReconnect(); }; + } + + /** + * Reconnect after an unexpected close — unless we were dropped because the + * server no longer knows this browser. Sessions live in the server's RAM + * (blueprint §9), so a restart refuses the WS upgrade, and a refused upgrade + * reaches `onclose` looking exactly like a flaky network: the loop used to + * retry every 2 s forever behind "Not connected", against a server that would + * never accept it again. So ask first, and let the re-login dialog take it + * from there — the socket comes back on `auth-restored`. + */ + async _scheduleReconnect() { + if (isSessionExpired()) return; // the dialog is already up + if ((await probeSession()) === 'expired') { + notifySessionExpired(); + // A shell that handles auth itself (native mobile) ignores the report; + // there is no dialog coming, so keep retrying as before. + if (isSessionExpired()) return; + } + setTimeout(() => this._connectWS(), 2000); + } + + /** + * A new session was obtained without leaving the page: reconnect and reconcile + * like any other unexpected disconnection (the `_reconnecting` flag is what + * makes `onopen` re-sync tool state that advanced while we were away). + */ + _onAuthRestored() { + if (this._ws && this._ws.readyState !== WebSocket.CLOSED) return; + this._reconnecting = true; + this._connectWS(); } /** @@ -665,14 +704,26 @@ export class ChatSession extends LightElement { // Don't send while an attachment is still streaming to disk, or its path // would be missing from the message. if (this._attachments.some(a => a.uploading)) return; - this._clearInput(); - + // Handled entirely over HTTP (and it reconnects the socket itself), so it must + // stay available precisely when the socket is down. if (content === '/new' || content === '/clear') { + this._clearInput(); this._attachments = []; await this._startNewSession(); return; } + // Nothing below this point may run while the socket is down: everything from + // here on is destructive to what the user typed (the input is cleared, the + // attachment chips are dropped). Bailing first is what keeps a long message + // recoverable — the composer still holds it, so retrying after the automatic + // reconnect is one Enter, not a retype. + if (this._ws?.readyState !== WebSocket.OPEN) { + this._pushError(t('chat.not_connected')); + return; + } + this._clearInput(); + // Strip client-only fields; the server persists these as message metadata. const attachments = this._attachments.map(({ name, path, mimetype, filesize }) => ({ name, path, mimetype, filesize })); @@ -692,13 +743,7 @@ export class ChatSession extends LightElement { this._push({ kind: 'user', content, attachments }); } this._waiting = true; - - if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify({ content, attachments })); - } else { - this._pushError('Not connected — reconnecting, please retry.'); - this._waiting = false; - } + this._ws.send(JSON.stringify({ content, attachments })); } // ── Attachments ──────────────────────────────────────────────────────────── diff --git a/web/lib/session-expiry.js b/web/lib/session-expiry.js new file mode 100644 index 0000000..4443e65 --- /dev/null +++ b/web/lib/session-expiry.js @@ -0,0 +1,96 @@ +/** + * Session-expiry detection for an already-open tab. + * + * A session lives in the server's RAM (blueprint §9), so a restart invalidates + * every token while the browser keeps happily sending its cookie. From that + * moment every gated `/api` call answers 401 and the chat socket is refused at + * the upgrade — and a refused upgrade is indistinguishable, in `onclose`, from a + * dropped wifi. That is why the chat used to sit forever behind "Not connected — + * reconnecting": it was reconnecting, correctly, to a server that will never + * accept it again. + * + * This module is the single place that turns "the server says I am nobody" into + * a fact the app can act on: the `auth-expired` event, answered by the re-login + * dialog (`components/session-relogin.js`), and `auth-restored` once a new + * session is in hand. Nothing here touches the DOM — reporting and reacting stay + * apart, so a second reactor (a banner, a mobile-specific screen) costs nothing. + */ + +let expired = false; + +// The native mobile shell authenticates in the background and must never be +// gated by a web login form (the rule `mobile.html`'s bootstrap already states). +// Guarding the report rather than each producer means no future caller can +// reintroduce the dialog there. +const NATIVE_SHELL = new URLSearchParams(location.search).get('native') === 'true'; + +/** True once the server has told us this browser has no session anymore. */ +export function isSessionExpired() { + return expired; +} + +/** + * Report a lost session. Idempotent and one-way: the first call fires the + * `auth-expired` window event, every later one is a no-op — several components + * discover the same 401 at once, and the dialog must be raised once. + */ +export function notifySessionExpired() { + if (expired || NATIVE_SHELL) return; + expired = true; + window.dispatchEvent(new CustomEvent('auth-expired')); +} + +/** + * Report that a fresh session has been obtained (the re-login dialog succeeded). + * Re-arms the detector and fires `auth-restored`, on which the live connections + * that gave up — the chat socket above all — pick themselves back up. + */ +export function notifySessionRestored() { + if (!expired) return; + expired = false; + window.dispatchEvent(new CustomEvent('auth-restored')); +} + +/** + * Ask the server whether this browser still has a session. + * + * Returns `'ok'`, `'expired'`, or `'unknown'` when the server could not be + * reached — the caller must treat that third case as "keep retrying", never as a + * logout: a box that is merely down comes back, and throwing the user at a login + * form they cannot submit would be strictly worse than waiting. + */ +export async function probeSession() { + try { + const res = await fetch('/api/auth/me'); + if (res.status === 401) return 'expired'; + return res.ok ? 'ok' : 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Wrap `window.fetch` so that a 401 from any gated `/api` endpoint reports an + * expired session, wherever in the app it happens. + * + * A wrapper rather than a helper every call site opts into: the components call + * `fetch` directly in dozens of places, and a seam that has to be remembered is + * one that will be forgotten by the next page. The auth endpoints are excluded + * because 401 is a *normal* answer there — `auth/me` is the "am I logged in?" + * probe and `auth/login` answers it to a wrong password; treating either as an + * expiry would raise the login screen from the login screen. + */ +export function installSessionExpiryWatch() { + const native = window.fetch.bind(window); + window.fetch = async (input, init) => { + const res = await native(input, init); + if (res.status === 401) { + const url = typeof input === 'string' ? input : (input?.url ?? ''); + const path = url.startsWith('http') ? new URL(url).pathname : url; + if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !path.startsWith('/api/setup/')) { + notifySessionExpired(); + } + } + return res; + }; +}