Recover silently from an expired session, and show a connection banner when offline
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
bda61ca560
commit
64efe45f0d
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue