Operator sign in
Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.
-diff --git a/README.md b/README.md index f3d088a..3d6afd4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,10 @@ that setting, a reverse proxy is safely treated as one shared source. Each signed cookie includes an opaque session identifier whose hash and expiry are kept in the SQLite session registry. Keep that registry on persistent, writable -storage shared by all dashboard workers. **Sign out & clear this device** revokes +storage shared by all dashboard workers. If an active session expires or is revoked, +the first authenticated API rejection replaces the dashboard with sign-in and explains +that private drafts remain on the device; signing in again resumes account-bound queued +delivery. This recovery does not clear offline state. **Sign out & clear this device** revokes only the current session before clearing browser state, so a copied cookie cannot be replayed afterward; other signed-in devices remain active. **Sign out all devices** is a separately confirmed lost-device safety action that atomically diff --git a/frontend/login.js b/frontend/login.js index 594d73f..0c5c70b 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -11,6 +11,11 @@ const clearIntervalImpl = options.clearIntervalImpl || clearInterval; let timer = null; + function showReason(reason) { + if (reason !== 'session-expired') return; + status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.'; + } + function showRetryCountdown(seconds) { let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1); button.disabled = true; @@ -54,7 +59,7 @@ status.textContent = 'Sign-in failed. Check the token and try again.'; } - return { submit }; + return { submit, showReason }; })); if (typeof document !== 'undefined') { @@ -68,6 +73,7 @@ if (typeof document !== 'undefined') { fetchImpl: fetch.bind(window), location: window.location, }); + controller.showReason(new URLSearchParams(window.location.search).get('reason')); form.addEventListener('submit', event => { event.preventDefault(); const accessToken = new FormData(form).get('access_token'); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 1a0a299..efbfb68 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v30'; +const CACHE = 'stackchain-dashboard-shell-v31'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/frontend/session.js b/frontend/session.js index 85211cc..318cee2 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -40,6 +40,7 @@ onClearError = () => {}, }) { const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + let expirationStarted = false; function csrfToken() { const entry = String(cookie?.() || '').split(';') @@ -63,7 +64,11 @@ requestOptions.headers = headers; } const response = await fetchImpl(input, requestOptions); - if (response.status === 401) onExpired(); + if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { + expirationStarted = true; + onExpired(); + location.replace(base + 'login?reason=session-expired'); + } return response; } diff --git a/src/views.py b/src/views.py index 41b3373..3b55077 100644 --- a/src/views.py +++ b/src/views.py @@ -13,7 +13,7 @@ LOGIN_HTML = """
Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.
-