Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-28 21:36:21 +02:00
parent bda61ca560
commit 64efe45f0d
7 changed files with 243 additions and 28 deletions

View File

@ -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',
];

View File

@ -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',
];

View File

@ -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 <body> (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);
},
}));
});

View File

@ -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.
--}}
<div
x-data="connectionBanner()"
@offline.window="markOffline()"
@online.window="markOnline()"
@connection-lost.window="markOffline()"
@connection-restored.window="markOnline()"
x-show="offline || justRestored"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2"
role="status" aria-live="polite"
class="fixed bottom-6 right-6 z-[80] flex items-center gap-2 rounded-pill border px-4 py-2.5 text-sm font-semibold shadow-md"
{{-- Same token pairs as <x-ui.alert variant="warning|success">; 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'"
>
<x-ui.icon name="alert-triangle" class="size-4" x-show="offline" />
<x-ui.icon name="check" class="size-4" x-show="!offline" />
<span x-text="offline ? @js(__('common.connection_lost')) : @js(__('common.connection_restored'))"></span>
</div>

View File

@ -10,7 +10,7 @@
worlds. It now carries density only: smaller type, tighter rows, tighter
corners. Colour comes from one place.
--}}
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'" data-login-url="{{ route('admin.login') }}">
@php
$release = App\Services\Deployment\Release::current();
@ -84,6 +84,8 @@
<span x-text="msg"></span>
</div>
<x-shell.connection-banner />
{{-- 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)

View File

@ -3,7 +3,7 @@
<head>
<x-shell.head :title="$title ?? config('app.name')" />
</head>
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'">
<body class="min-h-full bg-bg text-body antialiased" x-data="{ nav: false }" :class="nav && 'overflow-hidden lg:overflow-auto'" data-login-url="{{ route('login') }}">
@php
$maintenanceWindows = collect();
if (auth()->check()) {
@ -128,6 +128,8 @@
<span x-text="msg"></span>
</div>
<x-shell.connection-banner />
@livewire('wire-elements-modal')
@livewireScripts
</body>

View File

@ -0,0 +1,37 @@
<?php
use App\Models\User;
/**
* "Keine Verbindung" / "Verbindung wiederhergestellt".
*
* The banner itself and its 419 recovery are client-side (resources/js/app.js,
* resources/views/components/shell/connection-banner.blade.php) not
* something a request/response test can drive. What IS server-rendered, and
* what has broken silently before (R21: the console and the portal do not
* share a login), is which sign-in route each shell hands the JS as its
* reload-loop fallback, and that the banner is bound exactly once per page.
*/
it('gives the console its own sign-in route as the 419 fallback', function () {
$html = $this->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);
});