fix: renew a session that died under an open tab, instead of eating the message typed into it
Nightly Build / build (push) Successful in 7m34s
Nightly Build / build (push) Successful in 7m34s
Sessions live in the server's RAM, so a restart logs everyone out while the browser keeps sending a cookie nobody recognises. Nothing noticed: every gated API call answered 401 into a component that shrugged, and the chat socket was refused at the upgrade — which reaches `onclose` looking exactly like a flaky network, so the loop retried every 2 s forever behind "Not connected — reconnecting, please retry", against a server that would never accept it again. Retrying was not even the expensive part. `_send()` cleared the composer and dropped the attachment chips *before* testing the socket, so a long message was already destroyed by the time the error bubble appeared. The connection test now comes first and everything below it is unreachable while the socket is down, so the text stays where the user left it; `/new` and `/clear` move above the guard because they go over HTTP and reconnect the socket themselves, which is when they are most wanted. Detection is one module (`lib/session-expiry.js`) reporting a fact — `auth-expired`, and `auth-restored` on the way back — with nothing in it that touches the DOM. A `window.fetch` wrapper flags any 401 from a gated `/api` path, a wrapper rather than a helper each call site opts into because the components call `fetch` directly in dozens of places and a seam that must be remembered is one the next page will forget; `auth/*` and `setup/*` are excluded, where 401 is the normal answer. The socket's own path asks `probeSession()` before retrying, since it cannot tell a refusal from a blip. The native mobile shell is guarded inside the report, so no future caller can reintroduce a web login form there. The answer is a modal over the page the user is already on, not the login screen: bouncing to it would throw away everything the page was holding — including the half-written message this commit exists to save. One password field, prefilled with the last username this browser logged in as, not dismissible (with no session nothing on the page works, and a dialog you can wave away leaves a UI that silently fails every action). On success the chat reconnects on `auth-restored` and reconciles like any other disconnection. Known gap: pages that failed a fetch during the outage keep their stale data until navigated to again. Only the chat re-arms itself.
This commit is contained in:
@@ -38,6 +38,15 @@ import { LoginPage } from './components/login-page.js';
|
|||||||
import './lib/open-file.js';
|
import './lib/open-file.js';
|
||||||
import './lib/open-tool.js';
|
import './lib/open-tool.js';
|
||||||
import { initI18n } from './lib/i18n.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-topbar', AppTopbar);
|
||||||
customElements.define('app-sidebar', AppSidebar);
|
customElements.define('app-sidebar', AppSidebar);
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export class LoginPage extends I18nMixin(LightElement) {
|
|||||||
this._error = t('login.error');
|
this._error = t('login.error');
|
||||||
return;
|
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.
|
// Logged in — reload into the app.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { LitElement, html, nothing } from 'lit';
|
import { LitElement, html, nothing } from 'lit';
|
||||||
import { t } from '../lib/i18n.js';
|
import { t } from '../lib/i18n.js';
|
||||||
import { LoginPage } from './login-page.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/inbox-page.js';
|
||||||
import './shared/chat-page.js';
|
import './shared/chat-page.js';
|
||||||
import './shared/projects-page.js';
|
import './shared/projects-page.js';
|
||||||
@@ -10,6 +12,12 @@ import './shared/tool-detail-mobile.js';
|
|||||||
|
|
||||||
customElements.define('login-page', LoginPage);
|
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
|
// Sections addressable via the URL hash — same routing style as the desktop
|
||||||
// sidebar (web/components/sidebar.js). The native iOS shell and mobile browsers
|
// 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
|
// share this router so the URL always reflects the active section: native menu
|
||||||
|
|||||||
@@ -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`<span class="login-spinner"></span>${t('login.signing')}`
|
||||||
|
: t('login.submit');
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="relogin-backdrop">
|
||||||
|
<form class="login-card relogin-card" @submit=${this._submit} autocomplete="on">
|
||||||
|
<h2 class="login-title relogin-title">${t('login.expired.title')}</h2>
|
||||||
|
<p class="login-subtitle">${t('login.expired')}</p>
|
||||||
|
|
||||||
|
${this._error ? html`<div class="login-error">${this._error}</div>` : null}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">${t('login.username')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="username"
|
||||||
|
.value=${this._username}
|
||||||
|
@input=${e => this._username = e.target.value}
|
||||||
|
?disabled=${this._busy} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">${t('login.password')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
.value=${this._password}
|
||||||
|
@input=${e => this._password = e.target.value}
|
||||||
|
?disabled=${this._busy} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary login-submit" ?disabled=${this._busy}>
|
||||||
|
${btnLabel}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the dialog and wire it to `auth-expired`.
|
||||||
|
*
|
||||||
|
* The element appends itself to `<body>` 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -105,3 +105,41 @@
|
|||||||
.login-spinner {
|
.login-spinner {
|
||||||
animation-name: setup-spin;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
'chat.rejected': 'Denied.',
|
'chat.rejected': 'Denied.',
|
||||||
'chat.rejected_by_user': 'Denied by user.',
|
'chat.rejected_by_user': 'Denied by user.',
|
||||||
'chat.truncated': 'Response truncated by the token limit (↓{tokens} tok).',
|
'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 render ─────────────────────────────────────────────────────────
|
||||||
'copilot.open_in_viewer': 'Open in viewer',
|
'copilot.open_in_viewer': 'Open in viewer',
|
||||||
@@ -307,6 +308,8 @@ export default {
|
|||||||
// ── Login ──────────────────────────────────────────────────────────────────
|
// ── Login ──────────────────────────────────────────────────────────────────
|
||||||
'login.title': 'Welcome back',
|
'login.title': 'Welcome back',
|
||||||
'login.subtitle': 'Sign in to your account.',
|
'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.username': 'Username',
|
||||||
'login.password': 'Password',
|
'login.password': 'Password',
|
||||||
'login.submit': 'Sign in',
|
'login.submit': 'Sign in',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
'chat.rejected': 'Refusé.',
|
'chat.rejected': 'Refusé.',
|
||||||
'chat.rejected_by_user': 'Refusé par l\'utilisateur.',
|
'chat.rejected_by_user': 'Refusé par l\'utilisateur.',
|
||||||
'chat.truncated': 'Réponse tronquée par la limite de tokens (↓{tokens} tok).',
|
'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 render ─────────────────────────────────────────────────────────
|
||||||
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
|
'copilot.open_in_viewer': 'Ouvrir dans le visualiseur',
|
||||||
@@ -307,6 +308,8 @@ export default {
|
|||||||
// ── Login ──────────────────────────────────────────────────────────────────
|
// ── Login ──────────────────────────────────────────────────────────────────
|
||||||
'login.title': 'Bon retour',
|
'login.title': 'Bon retour',
|
||||||
'login.subtitle': 'Connectez-vous à votre compte.',
|
'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.username': 'Nom d\'utilisateur',
|
||||||
'login.password': 'Mot de passe',
|
'login.password': 'Mot de passe',
|
||||||
'login.submit': 'Se connecter',
|
'login.submit': 'Se connecter',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
'chat.rejected': 'Negata.',
|
'chat.rejected': 'Negata.',
|
||||||
'chat.rejected_by_user': 'Negata dall\'utente.',
|
'chat.rejected_by_user': 'Negata dall\'utente.',
|
||||||
'chat.truncated': 'Risposta troncata dal limite di token (↓{tokens} tok).',
|
'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 render ─────────────────────────────────────────────────────────
|
||||||
'copilot.open_in_viewer': 'Apri nel visualizzatore',
|
'copilot.open_in_viewer': 'Apri nel visualizzatore',
|
||||||
@@ -307,6 +308,8 @@ export default {
|
|||||||
// ── Accesso ────────────────────────────────────────────────────────────────
|
// ── Accesso ────────────────────────────────────────────────────────────────
|
||||||
'login.title': 'Bentornato',
|
'login.title': 'Bentornato',
|
||||||
'login.subtitle': 'Accedi al tuo account.',
|
'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.username': 'Nome utente',
|
||||||
'login.password': 'Password',
|
'login.password': 'Password',
|
||||||
'login.submit': 'Accedi',
|
'login.submit': 'Accedi',
|
||||||
|
|||||||
+54
-9
@@ -1,6 +1,7 @@
|
|||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { LightElement } from './base.js';
|
import { LightElement } from './base.js';
|
||||||
import { t } from './i18n.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
|
// 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
|
// 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
|
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
|
||||||
// is in flight the entry has `uploading: true` and no `path` yet.
|
// is in flight the entry has `uploading: true` and no `path` yet.
|
||||||
this._attachments = [];
|
this._attachments = [];
|
||||||
|
this._onAuthRestored = this._onAuthRestored.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
window.addEventListener('auth-restored', this._onAuthRestored);
|
||||||
// Fire-and-forget: availability of a transcription provider determines
|
// Fire-and-forget: availability of a transcription provider determines
|
||||||
// whether the mic button is rendered at all.
|
// whether the mic button is rendered at all.
|
||||||
this._checkTranscribe();
|
this._checkTranscribe();
|
||||||
@@ -120,6 +123,11 @@ export class ChatSession extends LightElement {
|
|||||||
this._connectWS();
|
this._connectWS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback?.();
|
||||||
|
window.removeEventListener('auth-restored', this._onAuthRestored);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Source identity — override in subclass ────────────────────────────────────
|
// ── Source identity — override in subclass ────────────────────────────────────
|
||||||
|
|
||||||
// Static default source for this component. Subclasses override (e.g. 'mobile').
|
// 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.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
|
// Don't send while an attachment is still streaming to disk, or its path
|
||||||
// would be missing from the message.
|
// would be missing from the message.
|
||||||
if (this._attachments.some(a => a.uploading)) return;
|
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') {
|
if (content === '/new' || content === '/clear') {
|
||||||
|
this._clearInput();
|
||||||
this._attachments = [];
|
this._attachments = [];
|
||||||
await this._startNewSession();
|
await this._startNewSession();
|
||||||
return;
|
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.
|
// Strip client-only fields; the server persists these as message metadata.
|
||||||
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
|
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
|
||||||
({ name, path, mimetype, filesize }));
|
({ name, path, mimetype, filesize }));
|
||||||
@@ -692,13 +743,7 @@ export class ChatSession extends LightElement {
|
|||||||
this._push({ kind: 'user', content, attachments });
|
this._push({ kind: 'user', content, attachments });
|
||||||
}
|
}
|
||||||
this._waiting = true;
|
this._waiting = true;
|
||||||
|
|
||||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
||||||
this._ws.send(JSON.stringify({ content, attachments }));
|
this._ws.send(JSON.stringify({ content, attachments }));
|
||||||
} else {
|
|
||||||
this._pushError('Not connected — reconnecting, please retry.');
|
|
||||||
this._waiting = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Attachments ────────────────────────────────────────────────────────────
|
// ── Attachments ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user