fix(modals): reliable open + spinner/error feedback on every modal trigger

Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.

Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.

CHANGELOG also backfills the prior reset-timing residual fix (2520b87).

Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-15 21:07:25 +02:00
parent 2520b87982
commit e58a08b974
17 changed files with 305 additions and 75 deletions

View File

@ -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 `<x-modal-trigger>`-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

View File

@ -46,4 +46,5 @@ return [
// Toaster fallback
'toast_done' => 'Erledigt',
'toast_modal_failed' => 'Konnte nicht geöffnet werden bitte erneut versuchen.',
];

View File

@ -46,4 +46,5 @@ return [
// Toaster fallback
'toast_done' => 'Done',
'toast_modal_failed' => 'Could not open — please try again.',
];

View File

@ -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 <body> 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 <x-modal-trigger>. 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;
}
},

View File

@ -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
<div x-data="cmdk(@js($nav), @js($actions), @js($servers))"
<div x-data="cmdk(@js($nav), @js($actions), @js($servers), @js(__('shell.toast_modal_failed')))"
@keydown.window="onKey($event)"
@keydown.escape.window="close()"
@cmdk-open.window="toggle(true)">

View File

@ -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.
--}}
<x-btn :variant="$variant" :size="$size" :icon="$icon" :attributes="$attributes->merge($triggerAttrs)">
<span x-show="!pending" class="contents">{{ $slot }}</span>
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" />
</svg>
</x-btn>

View File

@ -45,7 +45,14 @@
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
<x-command-palette />
<livewire:wire-elements-modal />
{{-- 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')
<livewire:wire-elements-modal />
@endpersist
@livewireScripts
</body>
</html>

View File

@ -94,7 +94,16 @@
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
</button>
@else
<button type="button" wire:click="edit({{ $loop->index }})" class="min-w-0 flex-1 truncate text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">{{ $e['name'] }}</button>
{{-- Filename opens the editor modal too inline modalTrigger keeps the
link layout but adds the pending state + timeout error toast. --}}
<button type="button"
x-data="modalTrigger(null, {}, @js(__('shell.toast_modal_failed')), 3500)"
wire:click="edit({{ $loop->index }})" x-on:click="arm()"
x-bind:disabled="pending" x-bind:aria-busy="pending"
class="flex min-w-0 flex-1 items-center gap-1.5 text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">
<span class="truncate">{{ $e['name'] }}</span>
<svg x-show="pending" x-cloak class="h-3 w-3 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
</button>
@endif
</div>
@ -120,9 +129,9 @@
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
<x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
<x-btn variant="secondary" wire:click="edit({{ $loop->index }})">{{ __('common.edit') }}</x-btn>
<x-modal-trigger variant="secondary" action="edit({{ $loop->index }})">{{ __('common.edit') }}</x-modal-trigger>
@endunless
<x-btn variant="danger-soft" wire:click="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-btn>
<x-modal-trigger variant="danger-soft" action="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-modal-trigger>
</div>
</div>
@endforeach

View File

@ -10,10 +10,10 @@
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2>
</div>
<div class="flex items-center gap-2">
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.create-server' })">
<x-modal-trigger variant="accent" component="modals.create-server">
<x-icon name="plus" class="h-3.5 w-3.5" />
{{ __('servers.add_server') }}
</x-btn>
</x-modal-trigger>
<x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill>
</div>
</div>

View File

@ -74,9 +74,9 @@
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.system-update', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" component="modals.system-update" :arguments="['serverId' => $server->id]">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('servers.system_updates') }}
</x-btn>
</x-modal-trigger>
<x-btn variant="secondary" wire:click="openFiles">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('servers.files') }}
</x-btn>
@ -118,17 +118,17 @@
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
<x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }}
</x-btn>
</x-modal-trigger>
<x-btn variant="secondary" wire:click="toggleCredential" wire:target="toggleCredential" wire:loading.attr="disabled">
<x-icon name="power" class="h-3.5 w-3.5" wire:loading.remove wire:target="toggleCredential" />
<svg wire:loading wire:target="toggleCredential" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ $cred->disabled ? __('common.unlock') : __('common.lock') }}
</x-btn>
<x-btn variant="danger-soft" wire:click="confirmDeleteCredential">
<x-modal-trigger variant="danger-soft" action="confirmDeleteCredential">
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
</x-btn>
</x-modal-trigger>
</div>
</div>
@else
@ -140,9 +140,9 @@
<p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p>
<p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p>
</div>
<x-btn variant="accent" class="shrink-0" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="accent" class="shrink-0" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }}
</x-btn>
</x-modal-trigger>
</div>
@endif
</x-panel>
@ -219,22 +219,23 @@
</div>
@if ($supported)
@if ($check['key'] === 'fail2ban')
<x-btn variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
</x-modal-trigger>
@endif
@if ($check['key'] === 'ssh_password' && $check['featureOn'])
{{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}}
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.ssh-key-provision', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.ssh-key-provision" :arguments="['serverId' => $server->id]">
{{ __('servers.ssh_key_provision_action') }}
</x-btn>
</x-modal-trigger>
@else
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: '{{ $check['key'] }}', enable: {{ $check['featureOn'] ? 'false' : 'true' }} } })">
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.hardening-action"
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
</x-btn>
</x-modal-trigger>
@endif
@endif
</div>
@ -298,10 +299,10 @@
:padded="false">
@if ($fwManageable)
<x-slot:actions>
<x-btn variant="accent" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.firewall-rule', arguments: { serverId: {{ $server->id }}, tool: '{{ $fwTool }}' } })">
<x-modal-trigger variant="accent" size="sm"
component="modals.firewall-rule" :arguments="['serverId' => $server->id, 'tool' => $fwTool]">
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }}
</x-btn>
</x-modal-trigger>
</x-slot:actions>
@endif
@ -313,10 +314,10 @@
@elseif ($fwTool === 'firewalld' && ! $fwActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p>
<x-btn variant="secondary" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'firewall', enable: true } })">
<x-modal-trigger variant="secondary" size="sm"
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]">
{{ __('common.enable') }}
</x-btn>
</x-modal-trigger>
</div>
@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') }}</span>
@unless ($fwReadOnly)
<x-btn variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
wire:click="confirmDeleteRule({{ $loop->index }})">
<x-modal-trigger variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
action="confirmDeleteRule({{ $loop->index }})">
<x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn>
</x-modal-trigger>
@endunless
</div>
@empty
@ -391,14 +392,15 @@
:padded="false">
@if ($f2bActive)
<x-slot:actions>
<x-btn variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-config', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
<x-icon name="settings" class="h-3.5 w-3.5" />
</x-btn>
<x-btn variant="accent" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-ban', arguments: { serverId: {{ $server->id }}, jails: {{ \Illuminate\Support\Js::from(array_map(fn ($j) => $j['name'], $f2bJails)) }} } })">
</x-modal-trigger>
<x-modal-trigger variant="accent" size="sm"
component="modals.fail2ban-ban"
:arguments="['serverId' => $server->id, 'jails' => array_map(fn ($j) => $j['name'], $f2bJails)]">
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }}
</x-btn>
</x-modal-trigger>
</x-slot:actions>
@endif
@ -410,10 +412,10 @@
@elseif (! $f2bActive)
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_state_installed_inactive') }}</p>
<x-btn variant="secondary" size="sm"
wire:click="$dispatch('openModal', { component: 'modals.hardening-action', arguments: { serverId: {{ $server->id }}, action: 'fail2ban', enable: true } })">
<x-modal-trigger variant="secondary" size="sm"
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'fail2ban', 'enable' => true]">
{{ __('common.enable') }}
</x-btn>
</x-modal-trigger>
</div>
@else
{{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left
@ -432,10 +434,10 @@
· <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span>
· {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}
</p>
<x-btn variant="secondary" size="sm" class="shrink-0"
wire:click="$dispatch('openModal', { component: 'modals.fail2ban-bans', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
component="modals.fail2ban-bans" :arguments="['serverId' => $server->id]">
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }}
</x-btn>
</x-modal-trigger>
</div>
{{-- Whitelist (ignoreip) --}}
@ -527,9 +529,9 @@
{{-- SSH keys --}}
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false">
<x-slot:actions>
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })">
<x-modal-trigger variant="accent" component="modals.add-ssh-key" :arguments="['serverId' => $server->id]">
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }}
</x-btn>
</x-modal-trigger>
</x-slot:actions>
<div class="divide-y divide-line">
@forelse ($sshKeys as $key)
@ -544,9 +546,9 @@
</div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div>
<x-btn variant="danger-soft" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
<x-modal-trigger variant="danger-soft" icon action="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
<x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn>
</x-modal-trigger>
</div>
@empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p>

View File

@ -71,9 +71,9 @@
{{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5">
<x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-btn>
<x-modal-trigger variant="secondary" action="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-modal-trigger>
<x-modal-trigger variant="secondary" action="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-modal-trigger>
<x-modal-trigger variant="accent" action="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-modal-trigger>
</div>
</div>
</div>

View File

@ -17,10 +17,10 @@
</div>
<div class="flex items-center gap-2">
@if ($twoFactorEnabled)
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
<x-modal-trigger variant="secondary" component="modals.recovery-codes">{{ __('auth.recovery_manage') }}</x-modal-trigger>
@endif
@if ($hasTotp)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
<x-modal-trigger variant="danger-soft" action="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-modal-trigger>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}

View File

@ -32,12 +32,12 @@
<div class="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
<p class="font-mono text-[11px] text-ink-4">{{ __('sessions.hint') }}</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<x-btn variant="danger-soft" class="shrink-0" wire:click="confirmLogoutOthers">
<x-modal-trigger variant="danger-soft" class="shrink-0" action="confirmLogoutOthers">
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('sessions.logout_others') }}
</x-btn>
<x-btn variant="danger" class="shrink-0" wire:click="confirmLogoutAll">
</x-modal-trigger>
<x-modal-trigger variant="danger" class="shrink-0" action="confirmLogoutAll">
<x-icon name="power" class="h-3.5 w-3.5" /> {{ __('sessions.logout_all') }}
</x-btn>
</x-modal-trigger>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
<div class="space-y-5">
<x-panel :title="__('accounts.title')" :subtitle="__('accounts.subtitle')">
<x-slot:actions>
<x-btn variant="accent" wire:click="create">
<x-modal-trigger variant="accent" action="create">
<x-icon name="user-plus" class="h-3.5 w-3.5" /> {{ __('accounts.create') }}
</x-btn>
</x-modal-trigger>
</x-slot:actions>
<div class="divide-y divide-line">
@ -37,12 +37,12 @@
@unless ($isSelf)
<div class="flex shrink-0 items-center gap-2">
<x-btn variant="secondary" wire:click="confirmLogout({{ $user->getKey() }})">
<x-modal-trigger variant="secondary" action="confirmLogout({{ $user->getKey() }})">
<x-icon name="logout" class="h-3.5 w-3.5" /> {{ __('accounts.logout') }}
</x-btn>
<x-btn variant="danger-soft" wire:click="confirmRemove({{ $user->getKey() }})">
</x-modal-trigger>
<x-modal-trigger variant="danger-soft" action="confirmRemove({{ $user->getKey() }})">
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('accounts.remove') }}
</x-btn>
</x-modal-trigger>
</div>
@endunless
</div>

View File

@ -19,9 +19,9 @@
· {{ $key->last_used_at ? __('auth.webauthn_last_used', ['date' => $key->last_used_at->diffForHumans()]) : __('auth.webauthn_never_used') }}
</p>
</div>
<x-btn variant="danger-soft" size="sm" class="shrink-0" wire:click="confirmRemove({{ $key->id }})">
<x-modal-trigger variant="danger-soft" size="sm" class="shrink-0" action="confirmRemove({{ $key->id }})">
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.remove') }}
</x-btn>
</x-modal-trigger>
</div>
@empty
<p class="py-3 font-mono text-[11px] text-ink-4">{{ __('auth.webauthn_none') }}</p>

View File

@ -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" />
<x-btn variant="primary" wire:click="confirmDomain" wire:loading.attr="disabled" class="shrink-0">
<x-modal-trigger variant="primary" action="confirmDomain" class="shrink-0">
<x-icon name="save" class="h-3.5 w-3.5" /> {{ __('common.save') }}
</x-btn>
</x-modal-trigger>
</div>
@error('domainInput')
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
@ -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')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_caddy') }}
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($tlsMode === 'caddy')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_caddy') }}</span>
</button>
<button type="button"
role="radio"
aria-checked="{{ $tlsMode === 'external' ? 'true' : 'false' }}"
wire:click="confirmTlsMode('external')"
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 === 'external',
'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'external',
])>
@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}</span>
</button>
</div>
@ -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)<x-icon name="git-branch" class="h-3.5 w-3.5" />@endif{{ $key }}
<svg x-show="pending" x-cloak class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span x-show="!pending" class="contents">@if ($channel === $key)<x-icon name="git-branch" class="h-3.5 w-3.5" />@endif{{ $key }}</span>
</button>
@endforeach
</div>

View File

@ -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"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-online"></span>
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="t.level === 'error' ? 'bg-offline' : 'bg-online'"></span>
<span class="font-mono text-xs text-ink-2" x-text="t.msg"></span>
</div>
</template>