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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user