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
parent
2520b87982
commit
e58a08b974
17
CHANGELOG.md
17
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 `<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
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,5 @@ return [
|
|||
|
||||
// Toaster fallback
|
||||
'toast_done' => 'Erledigt',
|
||||
'toast_modal_failed' => 'Konnte nicht geöffnet werden – bitte erneut versuchen.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -46,4 +46,5 @@ return [
|
|||
|
||||
// Toaster fallback
|
||||
'toast_done' => 'Done',
|
||||
'toast_modal_failed' => 'Could not open — please try again.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue