From 64efe45f0ddf98770390d20987afa0392f7b89c2 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 21:36:21 +0200 Subject: [PATCH] Recover silently from an expired session, and show a connection banner when offline Co-Authored-By: Claude Opus 5 --- lang/de/common.php | 18 ++- lang/en/common.php | 18 ++- resources/js/app.js | 143 ++++++++++++++++-- .../shell/connection-banner.blade.php | 47 ++++++ resources/views/layouts/admin.blade.php | 4 +- resources/views/layouts/portal-app.blade.php | 4 +- tests/Feature/ConnectionStateTest.php | 37 +++++ 7 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 resources/views/components/shell/connection-banner.blade.php create mode 100644 tests/Feature/ConnectionStateTest.php diff --git a/lang/de/common.php b/lang/de/common.php index 18de928..5b39e5b 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -2,11 +2,15 @@ return [ 'close' => 'Schließen', - 'tagline' => 'Kontrollzentrum für Ihre Cloud.', - 'sign_in' => 'Anmelden', - 'save' => 'Speichern', - 'cancel' => 'Abbrechen', - 'online' => 'Online', - 'offline' => 'Offline', - 'loading' => 'Wird geladen …', + 'tagline' => 'Kontrollzentrum für Ihre Cloud.', + 'sign_in' => 'Anmelden', + 'save' => 'Speichern', + 'cancel' => 'Abbrechen', + 'online' => 'Online', + 'offline' => 'Offline', + 'loading' => 'Wird geladen …', + + // Connection banner (resources/views/components/shell/connection-banner.blade.php). + 'connection_lost' => 'Keine Verbindung', + 'connection_restored' => 'Verbindung wiederhergestellt', ]; diff --git a/lang/en/common.php b/lang/en/common.php index aef88d2..c8c0054 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -2,11 +2,15 @@ return [ 'close' => 'Close', - 'tagline' => 'Control center for your cloud.', - 'sign_in' => 'Sign in', - 'save' => 'Save', - 'cancel' => 'Cancel', - 'online' => 'Online', - 'offline' => 'Offline', - 'loading' => 'Loading …', + 'tagline' => 'Control center for your cloud.', + 'sign_in' => 'Sign in', + 'save' => 'Save', + 'cancel' => 'Cancel', + 'online' => 'Online', + 'offline' => 'Offline', + 'loading' => 'Loading …', + + // Connection banner (resources/views/components/shell/connection-banner.blade.php). + 'connection_lost' => 'No connection', + 'connection_restored' => 'Connection restored', ]; diff --git a/resources/js/app.js b/resources/js/app.js index d94387d..e09e2b0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -72,24 +72,106 @@ function applyLineGradients(cfg) { } } -// A page that was open across a deploy carries a Livewire snapshot and a CSRF -// token the new code no longer accepts. The user sees "419 Page Expired" and a -// dead interface; the session itself is still fine, so a reload fixes it. +// ── Connection health: a silent 419 recovery, and a banner for everything +// that is not a Livewire-recognised failure at all ───────────────────── // -// Hooked at the network layer rather than through Livewire's request hook: the -// hook's failure callback is not invoked for this case in every version, and a -// silent no-op here is exactly the bug we are trying to remove. +// Livewire's OWN default handling of a failed request (vendor/livewire/ +// livewire/dist/livewire.js, js/request/index.js — read directly, not +// assumed, since the previous comment here turned out to be wrong about it) +// is not what it looks like from the outside: +// +// - A 419 (expired session or a stale CSRF token) triggers a native +// "This page has expired…" browser dialog (window's own confirm prompt) +// and THEN, whatever the operator answers, the same full-page HTML +// overlay used for a 500 — both fire unconditionally unless the request +// hook's preventDefault() is called. +// - A request that never gets a response at all (offline, DNS failure, +// connection refused — fetch() itself throwing) is already silent: no +// overlay, no dialog, nothing. Correct for case 3 below, but it also +// means a dropped connection currently gives the operator no feedback +// whatsoever — the gap the banner fills. +// - Anything else (500 and friends) is untouched — Livewire's own overlay +// is the right behaviour for a real server error; this project shows +// failures rather than hiding them (R22). +// +// preventDefault() runs synchronously inside Livewire's own fail(), before +// it decides whether to show that dialog and the overlay — so calling it +// here is not a race against window.location.reload() the way triggering +// the reload alone (the previous approach) was. +// +// A page left open across a deploy, or simply idle long enough, carries a +// CSRF token the server no longer accepts. Reloading gets a fresh one for a +// session that is still valid, and lands an actually-expired session +// straight on the sign-in page — both through completely ordinary Laravel +// middleware, nothing bespoke. +// +// sessionStorage survives the reload (same tab) but not a closed one: +// remembering that a reload was already tried means a SECOND 419 inside the +// window below is treated as "reloading did not fix it" rather than looped +// on forever, and sends the operator to sign in directly instead. A 419 in a +// fresh tab, or one long after the window has passed, always gets its own +// reload first. +const RELOAD_GUARD_KEY = 'clupilot:419-reload-at'; +const RELOAD_GUARD_WINDOW_MS = 15000; + +function recoverFrom419() { + const triedAt = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || 0); + + if (Date.now() - triedAt < RELOAD_GUARD_WINDOW_MS) { + sessionStorage.removeItem(RELOAD_GUARD_KEY); + // Each shell carries its OWN sign-in route on its (R21: the + // console and the portal do not share one) — see layouts/admin.blade.php + // and layouts/portal-app.blade.php. + window.location.href = document.body.dataset.loginUrl || '/login'; + + return; + } + + sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())); + window.location.reload(); +} + +document.addEventListener('livewire:init', () => { + window.Livewire.hook('request', ({ fail }) => { + fail(({ status, preventDefault }) => { + if (status !== 419) return; + + preventDefault(); + recoverFrom419(); + }); + }); +}); + +// Connection banner ("Keine Verbindung" / "Verbindung wiederhergestellt" — +// see connectionBanner() below): navigator.onLine and the browser's +// online/offline events are both used, but neither is trusted alone — +// onLine is only ever a hint (true on a wifi with no real route out, a +// captive portal, a VPN that dropped without the OS noticing), which is why +// an actual Livewire request completing or failing at the network level is +// wired in as a third, stronger signal. +// +// Wrapping fetch (module load, before Livewire's own bundle sends anything) +// rather than reading the request hook's fail() for this part: fail() only +// fires for a request Livewire itself made, and represents a thrown fetch as +// a synthetic status-503 failure — recognisable, but wrapping fetch directly +// observes the same thrown error without leaning on that internal detail. const originalFetch = window.fetch.bind(window); window.fetch = async (...args) => { - const response = await originalFetch(...args); + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; + const isLivewireRequest = url.includes('/livewire/'); - if (response.status === 419) { - const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; - if (url.includes('/livewire/')) { - window.location.reload(); - } + let response; + try { + response = await originalFetch(...args); + } catch (error) { + if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-lost')); + throw error; } + // Any response at all — even a 500 — proves the network path to our own + // server works, which is all "back online" means here. + if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-restored')); + return response; }; @@ -347,4 +429,41 @@ document.addEventListener('alpine:init', () => { this.instance?.destroy(); }, })); + + // ── Connection banner ────────────────────────────────────────────────── + // "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a + // few seconds once a signal below says it is back, then nothing. Bound + // once per page in each shell layout, listening for both the browser's + // own online/offline events and the connection-lost/connection-restored + // events the fetch wrapper above dispatches for real Livewire traffic. + // Whichever notices first wins; a wrong optimistic "restored" from the + // browser's online event self-corrects the moment the next Livewire + // request actually fails again. + window.Alpine.data('connectionBanner', () => ({ + // A page opened while already offline should say so immediately + // rather than wait for the first failed request. + offline: !navigator.onLine, + justRestored: false, + restoreTimer: null, + + markOffline() { + clearTimeout(this.restoreTimer); + this.justRestored = false; + this.offline = true; + }, + + markOnline() { + // Nothing to announce if this page was never marked offline. + if (!this.offline) return; + + this.offline = false; + this.justRestored = true; + clearTimeout(this.restoreTimer); + this.restoreTimer = setTimeout(() => { this.justRestored = false; }, 3000); + }, + + destroy() { + clearTimeout(this.restoreTimer); + }, + })); }); diff --git a/resources/views/components/shell/connection-banner.blade.php b/resources/views/components/shell/connection-banner.blade.php new file mode 100644 index 0000000..b63a5d8 --- /dev/null +++ b/resources/views/components/shell/connection-banner.blade.php @@ -0,0 +1,47 @@ +{{-- + "Keine Verbindung" / "Verbindung wiederhergestellt" — shared by both + shells (console and portal), unlike the toast a few lines away in each + layout. Deliberately its own element rather than reusing that toast: + + - The toast always self-dismisses after a fixed 3.2s. A dropped + connection can easily outlast that, and the message quietly + disappearing while still offline would read as "resolved" when it is + not. + - Both are `fixed`, bottom-anchored pills; keeping them at different + corners means an unrelated toast firing while this is up (a + client-side-only action, e.g. the VPN config "copied") never overlaps + it. + + The two icons are both always in the DOM with x-show toggling between + them, rather than one icon whose :class is swapped, so R18's "never + larger than the call site asked for" stays trivially true either way. + + See connectionBanner() in resources/js/app.js for the three signals + (navigator.onLine, the online/offline window events, and real Livewire + request failures) that drive offline/justRestored. +--}} +
; not + that component itself, since its class string is fixed at + server-render time and this toggles purely client-side. --}} + :class="offline ? 'border-warning-border bg-warning-bg text-warning' : 'border-success-border bg-success-bg text-success'" +> + + + +
diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index f2b6c4d..2693203 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -10,7 +10,7 @@ worlds. It now carries density only: smaller type, tighter rows, tighter corners. Colour comes from one place. --}} - + @php $release = App\Services\Deployment\Release::current(); @@ -84,6 +84,8 @@ + + {{-- Maintenance overlay: the console during a deployment. Bound HERE, once, rather than on the settings page's own update card — this is the only binding of updateWatcher (resources/js/app.js) diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 45dc0e9..7ea577b 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -3,7 +3,7 @@ - + @php $maintenanceWindows = collect(); if (auth()->check()) { @@ -128,6 +128,8 @@ + + @livewire('wire-elements-modal') @livewireScripts diff --git a/tests/Feature/ConnectionStateTest.php b/tests/Feature/ConnectionStateTest.php new file mode 100644 index 0000000..3025a51 --- /dev/null +++ b/tests/Feature/ConnectionStateTest.php @@ -0,0 +1,37 @@ +actingAs(operator('Owner'), 'operator') + ->get(route('admin.overview')) + ->assertOk() + ->getContent(); + + expect($html)->toContain('connectionBanner(') + ->and($html)->toContain('data-login-url="'.route('admin.login').'"') + ->and(substr_count($html, 'connectionBanner('))->toBe(1); +}); + +it('gives the portal its own sign-in route as the 419 fallback', function () { + $user = User::factory()->create(); + + $html = $this->actingAs($user) + ->get('/dashboard') + ->assertOk() + ->getContent(); + + expect($html)->toContain('connectionBanner(') + ->and($html)->toContain('data-login-url="'.route('login').'"') + ->and(substr_count($html, 'connectionBanner('))->toBe(1); +});