Release 0.2.0 #4
@@ -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',
|
||||||
|
|||||||
+55
-10
@@ -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;
|
||||||
|
this._ws.send(JSON.stringify({ content, attachments }));
|
||||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
||||||
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