From 218806727c3365f9d9f538451cb3bbe4bb7d708a Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 15:05:50 +0200 Subject: [PATCH] feat(r5): wire-elements/modal confirmations for destructive actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic ConfirmAction modal (LivewireUI\Modal\ModalComponent). On confirm it persists exactly one AuditEvent and re-dispatches a page event so the origin applies its own state change — domain-agnostic and reused everywhere (R5). Wired the destructive actions, each writing an audit row: - Services: start/stop/restart -> confirm + audit; service state reflects result - Files: delete -> confirm + audit; entry removed - Server-Details: revoke SSH key (new per-row trash button) -> confirm + audit (carries server_id) Supporting changes: - Publish + restyle the modal container for the dark theme (void backdrop + surface panel + shadow-pop instead of the package's gray/white defaults) - Toaster island in the app layout that catches the `notify` browser event - Add alert/power/rotate/trash icons to x-icon Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Files/Index.php | 34 +++++++++ app/Livewire/Modals/ConfirmAction.php | 75 +++++++++++++++++++ app/Livewire/Servers/Show.php | 35 +++++++++ app/Livewire/Services/Index.php | 45 ++++++++--- config/wire-elements-modal.php | 52 +++++++++++++ resources/views/components/icon.blade.php | 4 + resources/views/layouts/app.blade.php | 24 ++++++ .../views/livewire/files/index.blade.php | 4 +- .../livewire/modals/confirm-action.blade.php | 39 ++++++++++ .../views/livewire/servers/show.blade.php | 7 ++ .../views/livewire/services/index.blade.php | 6 +- .../wire-elements-modal/modal.blade.php | 57 ++++++++++++++ 12 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 app/Livewire/Modals/ConfirmAction.php create mode 100644 config/wire-elements-modal.php create mode 100644 resources/views/livewire/modals/confirm-action.blade.php create mode 100644 resources/views/vendor/wire-elements-modal/modal.blade.php diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index 6b35f90..891964e 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Files; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -52,6 +53,39 @@ class Index extends Component return $crumbs; } + /** + * Delete a file (R5): opens the confirm modal, which writes the AuditEvent + * and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later. + */ + public function confirmDelete(string $name): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => 'Datei löschen', + 'body' => "„{$name}“ wird unwiderruflich aus {$this->path} entfernt.", + 'confirmLabel' => 'Löschen', + 'danger' => true, + 'icon' => 'trash', + 'auditAction' => 'file.delete', + 'auditTarget' => rtrim($this->path, '/')."/{$name}", + 'event' => 'fileConfirmed', + 'params' => ['name' => $name], + 'notify' => "„{$name}“ gelöscht.", + ], + ); + } + + /** Applies the confirmed deletion (mock until SFTP unlink lands). */ + #[On('fileConfirmed')] + public function deleteEntry(string $name): void + { + $this->entries = array_values(array_filter( + $this->entries, + fn (array $e): bool => $e['name'] !== $name, + )); + } + public function render() { return view('livewire.files.index'); diff --git a/app/Livewire/Modals/ConfirmAction.php b/app/Livewire/Modals/ConfirmAction.php new file mode 100644 index 0000000..4c617f0 --- /dev/null +++ b/app/Livewire/Modals/ConfirmAction.php @@ -0,0 +1,75 @@ + */ + public array $params = []; + + public string $notify = 'Aktion ausgeführt.'; + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function confirm(): void + { + if ($this->auditAction !== '') { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $this->serverId, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $this->auditAction, + 'target' => $this->auditTarget, + 'ip' => request()->ip(), + ]); + } + + if ($this->event !== '') { + $this->dispatch($this->event, ...$this->params); + } + + $this->dispatch('notify', message: $this->notify); + + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.confirm-action'); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index c8464d5..a80b527 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\Servers; use App\Models\Server; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -57,6 +58,40 @@ class Show extends Component ]; } + /** + * Revoke an SSH key (R5): opens the confirm modal, which writes the + * AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later. + */ + public function confirmKeyRemoval(string $fingerprint, string $comment): void + { + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => 'SSH-Schlüssel entfernen', + 'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.", + 'confirmLabel' => 'Entfernen', + 'danger' => true, + 'icon' => 'trash', + 'auditAction' => 'ssh_key.remove', + 'auditTarget' => "{$comment} · {$this->server->name}", + 'serverId' => $this->server->id, + 'event' => 'keyRemoved', + 'params' => ['fingerprint' => $fingerprint], + 'notify' => "Schlüssel „{$comment}“ entfernt.", + ], + ); + } + + /** Applies the confirmed key removal (mock until SSH layer lands). */ + #[On('keyRemoved')] + public function removeKey(string $fingerprint): void + { + $this->sshKeys = array_values(array_filter( + $this->sshKeys, + fn (array $k): bool => $k['fingerprint'] !== $fingerprint, + )); + } + public function render() { return view('livewire.servers.show'); diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index a65e9b2..11632a7 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\Services; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; @@ -63,22 +64,44 @@ class Index extends Component } /** - * Service control. Wired to the SSH layer (systemctl start/stop/restart) - * behind a confirmation modal later — see R5 markers in the view. + * Service control (R5): opens the wire-elements/modal confirm dialog. The + * modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which + * applyService() handles. SSH exec (systemctl) is wired in behind this later. */ - public function start(string $name): void + public function confirm(string $op, string $name): void { - // R5: routed through wire-elements/modal + SSH exec later. + [$heading, $phrase, $label, $action, $icon] = match ($op) { + 'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'], + 'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'], + default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'], + }; + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => $heading, + 'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.", + 'confirmLabel' => $label, + 'danger' => $op === 'stop', + 'icon' => $icon, + 'auditAction' => $action, + 'auditTarget' => "{$name} · {$this->server}", + 'event' => 'serviceConfirmed', + 'params' => ['op' => $op, 'name' => $name], + 'notify' => "{$name}: {$label} ausgeführt.", + ], + ); } - public function stop(string $name): void + /** Applies the confirmed service state change (mock until SSH exec lands). */ + #[On('serviceConfirmed')] + public function applyService(string $op, string $name): void { - // R5: routed through wire-elements/modal + SSH exec later. - } - - public function restart(string $name): void - { - // R5: routed through wire-elements/modal + SSH exec later. + foreach ($this->services as $i => $svc) { + if ($svc['name'] === $name) { + $this->services[$i]['status'] = $op === 'stop' ? 'offline' : 'online'; + } + } } /** @return array */ diff --git a/config/wire-elements-modal.php b/config/wire-elements-modal.php new file mode 100644 index 0000000..2950291 --- /dev/null +++ b/config/wire-elements-modal.php @@ -0,0 +1,52 @@ + false, + + /* + |-------------------------------------------------------------------------- + | Include JS + |-------------------------------------------------------------------------- + | + | Livewire UI will inject the required Javascript in your blade template. + | If you want to bundle the required Javascript you can set this to false + | and add `require('vendor/wire-elements/modal/resources/js/modal');` + | to your script bundler like webpack. + | + */ + 'include_js' => true, + + /* + |-------------------------------------------------------------------------- + | Modal Component Defaults + |-------------------------------------------------------------------------- + | + | Configure the default properties for a modal component. + | + | Supported modal_max_width + | 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl' + */ + 'component_defaults' => [ + 'modal_max_width' => '2xl', + + 'close_modal_on_click_away' => true, + + 'close_modal_on_escape' => true, + + 'close_modal_on_escape_is_forceful' => true, + + 'dispatch_close_event' => false, + + 'destroy_on_close' => false, + ], +]; diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 6f7ba0e..eea1d2f 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -16,6 +16,10 @@ 'shield' => '', 'plus' => '', 'logout' => '', + 'alert' => '', + 'power' => '', + 'rotate' => '', + 'trash' => '', ]; @endphp merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor" diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 2e70625..d2a309e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -25,6 +25,30 @@ + {{-- Toaster: catches Livewire-dispatched `notify` browser events --}} +
+ +
+ + @livewireScripts diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 3f4f0a4..ebf5fe2 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -118,8 +118,8 @@ - {{-- R5: wire to wire-elements/modal --}} - diff --git a/resources/views/livewire/modals/confirm-action.blade.php b/resources/views/livewire/modals/confirm-action.blade.php new file mode 100644 index 0000000..4c90045 --- /dev/null +++ b/resources/views/livewire/modals/confirm-action.blade.php @@ -0,0 +1,39 @@ +
+
+ $danger, + 'border-accent/30 bg-accent/10 text-accent-text' => ! $danger, + ])> + + +
+

{{ $heading }}

+

{{ $body }}

+ @if ($auditTarget) +

{{ $auditTarget }}

+ @endif +
+
+ +
+ + +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 6ded799..f97eb62 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -161,6 +161,13 @@

{{ $key['fingerprint'] }}

+ {{-- R5: destructive → wire-elements/modal confirm + audit --}} + @endforeach diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php index 5149c15..95c4686 100644 --- a/resources/views/livewire/services/index.blade.php +++ b/resources/views/livewire/services/index.blade.php @@ -63,19 +63,19 @@
diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php new file mode 100644 index 0000000..a7a20a2 --- /dev/null +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -0,0 +1,57 @@ +
+ @isset($jsPath) + + @endisset + @isset($cssPath) + + @endisset + + +