diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c5b10..5291770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,22 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui ## [Unreleased] -_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +### Behoben +- **Modal-Trigger zuverlässig + mit Feedback.** Modale gingen direkt nach einer Navigation teils + erst beim 2.–3. Klick auf (Konsole: „Could not find Livewire component in DOM tree"), und der + Button gab kein Signal. Ursache: der globale Modal-Manager wurde bei jedem `wire:navigate` ab- + und neu aufgebaut, sodass sein `openModal`-Listener kurz fehlte. Er bleibt jetzt über `@persist` + für die ganze Sitzung bestehen — Modale öffnen beim ersten Klick. Zusätzlich zeigt **jeder** + Modal-Trigger (in allen Views) ab Klick sofort einen Spinner und blendet einen Fehler-Toast ein, + falls nach kurzer Zeit nichts aufgeht (neues ``-Element; der Toaster kennt jetzt + eine rote Fehler-Variante). So kann ein Klick nie tot wirken und ein echtes Fehlschlagen ist nie + stumm. + +### Sicherheit +- **Passwort-Reset-Timing weiter abgeflacht.** Der Dummy-Verifikationspfad für unbekannte Konten + spiegelt jetzt exakt die Kosten der echten Recovery-Code-Prüfung (gesperrtes `SELECT` in einer + Transaktion statt eines schwereren bcrypt-Vergleichs), und Transportfehler beim Reset-Link werden + geloggt statt stillschweigend verschluckt. ## [0.9.2] - 2026-06-15 diff --git a/lang/de/shell.php b/lang/de/shell.php index 70798f4..5f32f0f 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -46,4 +46,5 @@ return [ // Toaster fallback 'toast_done' => 'Erledigt', + 'toast_modal_failed' => 'Konnte nicht geöffnet werden – bitte erneut versuchen.', ]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 277c27e..02f33ad 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -46,4 +46,5 @@ return [ // Toaster fallback 'toast_done' => 'Done', + 'toast_modal_failed' => 'Could not open — please try again.', ]; diff --git a/resources/js/app.js b/resources/js/app.js index 1fd36a0..a4878d9 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -24,7 +24,107 @@ window.Echo = new Echo({ auth: { headers: { 'X-CSRF-TOKEN': csrfToken } }, }); +// ── Modal-open detection ──────────────────────────────────────────────── +// wire-elements/modal emits NO "opened" event; the only reliable, package-version- +// agnostic signal is that it adds `overflow-y-hidden` to in setShowPropertyTo(true). +// watchModalOpen() resolves the moment that class appears, or fires onDone(true) after +// `timeout` ms so a silently-failed open (e.g. a click that raced a morph) still surfaces +// an error instead of a dead button. Returns a silent cancel() for teardown. +function watchModalOpen(timeout, onDone) { + let settled = false; + let timer = null; + const settle = (timedOut) => { + if (settled) return; + settled = true; + obs.disconnect(); + if (timer) clearTimeout(timer); + onDone?.(timedOut); + }; + const obs = new MutationObserver(() => { + if (document.body.classList.contains('overflow-y-hidden')) settle(false); + }); + obs.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + timer = setTimeout(() => settle(true), timeout); + + return () => { // silent teardown (component destroyed) — no onDone + if (settled) return; + settled = true; + obs.disconnect(); + if (timer) clearTimeout(timer); + }; +} + +// Surface a red (level:error) toast through the shared toaster (partials/toaster.blade.php). +function fireErrorToast(message) { + window.dispatchEvent(new CustomEvent('notify', { detail: { message, level: 'error' } })); +} + +// Last time ANY Livewire request was sent. A modal trigger uses this to tell a genuine +// client-side resolution failure (the click never reached the server — NO commit, e.g. a +// click that raced a morph) apart from a legitimate server-side no-op (the method ran and +// chose not to open a modal, or rendered a validation error — which DID commit). Only the +// former warrants the "could not open" toast. +// +// Deliberately GLOBAL (not filtered to the modal-manager component): a server method that +// no-ops (e.g. confirmTlsMode on the already-active mode) commits the PAGE component, not the +// manager — filtering to the manager would wrongly flag those as failures (false-positive +// toast). The accepted cost is the reverse: on a polling page a poll commit landing inside the +// watchdog window can mask a genuinely-lost click (missed toast). We err toward never crying +// wolf — the spinner still showed, so the click never looks dead. +let lastLivewireCommitAt = 0; +document.addEventListener('livewire:init', () => { + window.Livewire.hook('commit', () => { lastLivewireCommitAt = Date.now(); }); +}); + document.addEventListener('alpine:init', () => { + // ── Modal trigger ─────────────────────────────────────────────────── + // Reused by . Gives EVERY modal-opening control an immediate + // spinner (pending flips synchronously on click — no round-trip needed) that clears + // the instant the modal mounts, and an error toast if it never appears within the + // timeout. open() = client-side dispatch; arm() = a server wire:click method that + // dispatches openModal itself (we only watch + spin). + window.Alpine.data('modalTrigger', (component = null, args = {}, failMsg = '', timeout = 2200) => ({ + pending: false, + _cancel: null, + + open() { + if (this.pending) return; + this._begin(); + window.Livewire?.dispatch('openModal', { component, arguments: args }); + }, + + arm() { + if (this.pending) return; + this._begin(); + }, + + _begin() { + this.pending = true; + const clickedAt = Date.now(); + this._cancel = watchModalOpen(this._timeout(), (timedOut) => { + this.pending = false; + this._cancel = null; + // Error ONLY when the modal never opened AND nothing committed since the click + // (a true silent failure — the click didn't reach the server). A server method + // that legitimately committed without opening (no-op / validation) stays quiet; + // it surfaces its own feedback via Livewire. + if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(failMsg); + }); + }, + + _timeout() { + return Number.isFinite(timeout) && timeout > 0 ? timeout : 2200; + }, + + destroy() { + if (this._cancel) { + this._cancel(); + this._cancel = null; + } + this.pending = false; + }, + })); + // Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then // appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100). window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40, server = null) => ({ @@ -87,16 +187,18 @@ document.addEventListener('alpine:init', () => { // e=Einstellungen, y=sYstem — d/s already taken) and stays in JS. const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' }; - window.Alpine.data('cmdk', (nav = [], actions = [], servers = []) => ({ + window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({ nav, actions, servers, + failMsg, open: false, help: false, query: '', active: 0, leader: false, leaderTimer: null, + _cancelModalWatch: null, init() { // Reset overlays after an SPA navigation. The component is recreated on each @@ -112,6 +214,10 @@ document.addEventListener('alpine:init', () => { destroy() { document.removeEventListener('livewire:navigated', this._onNav); + if (this._cancelModalWatch) { + this._cancelModalWatch(); + this._cancelModalWatch = null; + } }, get items() { @@ -151,7 +257,17 @@ document.addEventListener('alpine:init', () => { this.navigate(item.href); } else if (item.action === 'create-server') { this.close(); + // No per-button spinner here (the palette closes), but still surface an error + // toast if the modal never appears AND nothing committed (same discriminator as + // modalTrigger). Store the canceller so destroy() can tear it down on navigation. + if (this._cancelModalWatch) this._cancelModalWatch(); // tear down any in-flight watcher first + const clickedAt = Date.now(); window.Livewire?.dispatch('openModal', { component: 'modals.create-server' }); + const watch = watchModalOpen(2200, (timedOut) => { + if (this._cancelModalWatch === watch) this._cancelModalWatch = null; // only clear our own handle + if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(this.failMsg); + }); + this._cancelModalWatch = watch; } }, diff --git a/resources/views/components/command-palette.blade.php b/resources/views/components/command-palette.blade.php index b2aeeb6..b54f511 100644 --- a/resources/views/components/command-palette.blade.php +++ b/resources/views/components/command-palette.blade.php @@ -45,7 +45,7 @@ $kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2'; @endphp -
diff --git a/resources/views/components/modal-trigger.blade.php b/resources/views/components/modal-trigger.blade.php new file mode 100644 index 0000000..94546d8 --- /dev/null +++ b/resources/views/components/modal-trigger.blade.php @@ -0,0 +1,63 @@ +@props([ + // Client mode: dispatch openModal directly from the browser. + 'component' => null, // e.g. 'modals.edit-credential' + 'arguments' => [], // assoc array, e.g. ['serverId' => $server->id] + // Server mode: a wire:click method that itself dispatches openModal (e.g. a confirm + // action that must issue a signed token first). e.g. "confirmKeyRemoval(3)". + 'action' => null, + // Passthrough to the underlying button component (variant/size/icon). + 'variant' => 'secondary', + 'size' => 'sm', + 'icon' => false, + // Override the timeout error-toast copy (defaults to the shared shell key). + 'failMessage' => null, +]) +@php + // Server-mode opens take a round-trip before the modal mounts -> a longer watchdog. + $timeout = $action ? 3500 : 2200; + $fail = $failMessage ?? __('shell.toast_modal_failed'); + + $xData = 'modalTrigger(' + .\Illuminate\Support\Js::from($component).', ' + .\Illuminate\Support\Js::from((object) $arguments).', ' + .\Illuminate\Support\Js::from($fail).', ' + .$timeout.')'; + + // All dynamic attributes are built here and forwarded to the button via :attributes — + // NOT as a directive/echo inside the component tag (block directives compile to broken + // PHP, and a raw attributes->merge() echo inside a component tag is not treated as a bag). + // Server mode keeps wire:click + arms the watchdog; client mode dispatches via open(). + // + // While pending we block re-clicks + dim via a class — NOT x-bind:disabled, which would + // clobber a caller's status-based :disabled (e.g. services start/stop disabled by state). + $triggerAttrs = [ + 'x-data' => $xData, + 'x-bind:aria-busy' => 'pending', + 'x-bind:class' => "pending && 'pointer-events-none opacity-70'", + // Capture-phase guard: while pending, swallow the click BEFORE wire:click/open() run — + // blocks keyboard (Enter/Space) and programmatic re-activation too, not just the mouse + // (pointer-events-none only covers the pointer), so a server-mode wire:click can't double-fire. + 'x-on:click.capture' => 'if (pending) { $event.preventDefault(); $event.stopImmediatePropagation(); }', + 'x-on:click' => $action ? 'arm()' : 'open()', + ]; + if ($action) { + $triggerAttrs['wire:click'] = $action; + } +@endphp +{{-- + One trigger for every modal in the app (R10). It ALWAYS shows an immediate spinner on + click (Alpine flips `pending` synchronously — no round-trip needed) and clears it the + instant the modal mounts. If the modal never appears within the timeout, it raises an + error toast ONLY when the click produced no Livewire commit at all (a true silent + failure); a server method that committed but chose not to open (no-op / validation) + stays quiet and surfaces its own feedback. So a click can never look dead, and a + genuinely failed open is never silent. See window.Alpine.data('modalTrigger') in + resources/js/app.js. +--}} + + {{ $slot }} + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 4156df9..3d9143c 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -45,7 +45,14 @@ {{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}} - + {{-- Persist the modal manager across wire:navigate: otherwise body.replaceWith + + Alpine.destroyTree tear down its window-level `openModal` listener on every SPA + navigation, leaving a post-landing window where a click finds no live component + (dead trigger / "Could not find Livewire component in DOM tree"). @persist keeps + ONE stable manager + listener for the whole session. --}} + @persist('modal') + + @endpersist @livewireScripts diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 8fc11b1..ba1d5c1 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -94,7 +94,16 @@ {{ $e['name'] }}/ @else - + {{-- Filename opens the editor modal too — inline modalTrigger keeps the + link layout but adds the pending state + timeout error toast. --}} + @endif
@@ -120,9 +129,9 @@
@unless ($isDir) {{ __('files.download') }} - {{ __('common.edit') }} + {{ __('common.edit') }} @endunless - {{ __('common.delete') }} + {{ __('common.delete') }}
@endforeach diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php index d1e6b79..19df232 100644 --- a/resources/views/livewire/servers/index.blade.php +++ b/resources/views/livewire/servers/index.blade.php @@ -10,10 +10,10 @@

{{ __('servers.heading') }}

- + {{ __('servers.add_server') }} - + {{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index d5f5545..36c69c8 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -74,9 +74,9 @@
- + {{ __('servers.system_updates') }} - + {{ __('servers.files') }} @@ -118,17 +118,17 @@

- + {{ __('common.edit') }} - + {{ $cred->disabled ? __('common.unlock') : __('common.lock') }} - + {{ __('common.delete') }} - +
@else @@ -140,9 +140,9 @@

{{ __('servers.cred_none_title') }}

{{ __('servers.cred_none_hint') }}

- + {{ __('servers.cred_deposit') }} - +
@endif @@ -219,22 +219,23 @@ @if ($supported) @if ($check['key'] === 'fail2ban') - + - +
@endif @if ($check['key'] === 'ssh_password' && $check['featureOn']) {{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}} - + {{ __('servers.ssh_key_provision_action') }} - + @else - + {{ $check['featureOn'] ? __('common.disable') : __('common.enable') }} - + @endif @endif @@ -298,10 +299,10 @@ :padded="false"> @if ($fwManageable) - + {{ __('servers.firewall_add_rule') }} - + @endif @@ -313,10 +314,10 @@ @elseif ($fwTool === 'firewalld' && ! $fwActive)

{{ __('servers.firewalld_inactive') }}

- + {{ __('common.enable') }} - +
@else {{-- Lone firewall: defaults/state in the left column, rules list in the @@ -367,10 +368,10 @@ 'text-offline' => $ruleTone === 'offline', ])>{{ strtolower($rule['action'] ?? 'allow') }} @unless ($fwReadOnly) - + - + @endunless @empty @@ -391,14 +392,15 @@ :padded="false"> @if ($f2bActive) - + - - + + {{ __('servers.fail2ban_ban_ip') }} - + @endif @@ -410,10 +412,10 @@ @elseif (! $f2bActive)

{{ __('servers.fail2ban_state_installed_inactive') }}

- + {{ __('common.enable') }} - +
@else {{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left @@ -432,10 +434,10 @@ · $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }} · {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}

- + {{ __('servers.fail2ban_view_bans') }} - + {{-- Whitelist (ignoreip) --}} @@ -527,9 +529,9 @@ {{-- SSH keys --}} - + {{ __('servers.ssh_keys_add') }} - +
@forelse ($sshKeys as $key) @@ -544,9 +546,9 @@

{{ $key['fingerprint'] }}

- + - + @empty

{{ __('servers.ssh_keys_none') }}

diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php index 640d239..0595b5b 100644 --- a/resources/views/livewire/services/index.blade.php +++ b/resources/views/livewire/services/index.blade.php @@ -71,9 +71,9 @@ {{-- R5: wire to wire-elements/modal --}}
- {{ __('services.start') }} - {{ __('services.stop') }} - {{ __('services.restart') }} + {{ __('services.start') }} + {{ __('services.stop') }} + {{ __('services.restart') }}
diff --git a/resources/views/livewire/settings/security.blade.php b/resources/views/livewire/settings/security.blade.php index faff0a2..2d639b0 100644 --- a/resources/views/livewire/settings/security.blade.php +++ b/resources/views/livewire/settings/security.blade.php @@ -17,10 +17,10 @@
@if ($twoFactorEnabled) - {{ __('auth.recovery_manage') }} + {{ __('auth.recovery_manage') }} @endif @if ($hasTotp) - {{ __('settings.twofa_remove_totp') }} + {{ __('settings.twofa_remove_totp') }} @else {{ __('settings.twofa_setup') }} diff --git a/resources/views/livewire/settings/sessions.blade.php b/resources/views/livewire/settings/sessions.blade.php index 86843de..a157413 100644 --- a/resources/views/livewire/settings/sessions.blade.php +++ b/resources/views/livewire/settings/sessions.blade.php @@ -32,12 +32,12 @@

{{ __('sessions.hint') }}

- + {{ __('sessions.logout_others') }} - - + + {{ __('sessions.logout_all') }} - +
diff --git a/resources/views/livewire/settings/users.blade.php b/resources/views/livewire/settings/users.blade.php index c438392..534a5b5 100644 --- a/resources/views/livewire/settings/users.blade.php +++ b/resources/views/livewire/settings/users.blade.php @@ -1,9 +1,9 @@
- + {{ __('accounts.create') }} - +
@@ -37,12 +37,12 @@ @unless ($isSelf)
- + {{ __('accounts.logout') }} - - + + {{ __('accounts.remove') }} - +
@endunless
diff --git a/resources/views/livewire/settings/webauthn-keys.blade.php b/resources/views/livewire/settings/webauthn-keys.blade.php index 49a8789..3b873ad 100644 --- a/resources/views/livewire/settings/webauthn-keys.blade.php +++ b/resources/views/livewire/settings/webauthn-keys.blade.php @@ -19,9 +19,9 @@ · {{ $key->last_used_at ? __('auth.webauthn_last_used', ['date' => $key->last_used_at->diffForHumans()]) : __('auth.webauthn_never_used') }}

- + {{ __('common.remove') }} - + @empty

{{ __('auth.webauthn_none') }}

diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 3a8c110..a11c059 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -79,9 +79,9 @@ autocapitalize="off" spellcheck="false" placeholder="{{ __('system.domain_placeholder') }}" class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> - + {{ __('common.save') }} - + @error('domainInput')

{{ $message }}

@@ -99,23 +99,33 @@ role="radio" aria-checked="{{ $tlsMode === 'caddy' ? 'true' : 'false' }}" wire:click="confirmTlsMode('caddy')" + x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)" + x-on:click="arm()" + x-bind:disabled="pending" + x-bind:aria-busy="pending" @class([ 'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors', 'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $tlsMode === 'caddy', 'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'caddy', ])> - @if ($tlsMode === 'caddy')@endif{{ __('system.tls_mode_caddy') }} + + @if ($tlsMode === 'caddy')@endif{{ __('system.tls_mode_caddy') }} @@ -199,12 +209,17 @@ role="radio" aria-checked="{{ $channel === $key ? 'true' : 'false' }}" wire:click="confirmChannel('{{ $key }}')" + x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)" + x-on:click="arm()" + x-bind:disabled="pending" + x-bind:aria-busy="pending" @class([ 'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors', 'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $channel === $key, 'text-ink-3 hover:bg-raised hover:text-ink-2' => $channel !== $key, ])> - @if ($channel === $key)@endif{{ $key }} + + @if ($channel === $key)@endif{{ $key }} @endforeach diff --git a/resources/views/partials/toaster.blade.php b/resources/views/partials/toaster.blade.php index 9ee0f90..663d566 100644 --- a/resources/views/partials/toaster.blade.php +++ b/resources/views/partials/toaster.blade.php @@ -4,8 +4,9 @@ x-data="{ toasts: [] }" x-on:notify.window=" const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? @js(__('shell.toast_done')); + const level = $event.detail?.level ?? $event.detail?.[0]?.level ?? 'success'; const id = (window.crypto?.randomUUID?.() ?? String(Date.now() + Math.random())); - toasts.push({ id, msg }); + toasts.push({ id, msg, level }); setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000); " class="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4 sm:items-end sm:px-6" @@ -16,7 +17,7 @@ x-transition.opacity.duration.200ms class="pointer-events-auto flex items-center gap-2.5 rounded-md border border-line bg-raised px-4 py-2.5 shadow-pop" > - +