feat(r5): wire-elements/modal confirmations for destructive actions

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 15:05:50 +02:00
parent dc432e7335
commit 218806727c
12 changed files with 366 additions and 16 deletions

View File

@ -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');

View File

@ -0,0 +1,75 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Generic R5 confirmation dialog for destructive / stateful actions.
*
* The opener passes copy + an audit descriptor + a follow-up event. On confirm
* we persist exactly one AuditEvent and re-dispatch the opener's event so the
* originating page can apply its own state change. The modal stays
* domain-agnostic and is reused for every confirm in the app (R5).
*/
class ConfirmAction extends ModalComponent
{
public string $heading = 'Aktion bestätigen';
public string $body = '';
public string $confirmLabel = 'Bestätigen';
public bool $danger = false;
public string $icon = '';
/** Audit descriptor — one row written on confirm. */
public string $auditAction = '';
public ?string $auditTarget = null;
public ?int $serverId = null;
/** Follow-up event re-dispatched to the page after a successful confirm. */
public string $event = '';
/** @var array<string, mixed> */
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');
}
}

View File

@ -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');

View File

@ -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<int, array{name:string,status:string,desc:string,enabled:bool}> */

View File

@ -0,0 +1,52 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Include CSS
|--------------------------------------------------------------------------
|
| The modal uses TailwindCSS, if you don't use TailwindCSS you will need
| to set this parameter to true. This includes the modern-normalize css.
|
*/
'include_css' => 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,
],
];

View File

@ -16,6 +16,10 @@
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'power' => '<path d="M12 2v10"/><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>',
'rotate' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'trash' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
];
@endphp
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor"

View File

@ -25,6 +25,30 @@
</main>
</div>
</div>
{{-- Toaster: catches Livewire-dispatched `notify` browser events --}}
<div
x-data="{ toasts: [] }"
x-on:notify.window="
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? 'Erledigt';
const id = (window.crypto?.randomUUID?.() ?? String(Date.now() + Math.random()));
toasts.push({ id, msg });
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"
aria-live="polite"
>
<template x-for="t in toasts" :key="t.id">
<div
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="font-mono text-xs text-ink-2" x-text="t.msg"></span>
</div>
</template>
</div>
<livewire:wire-elements-modal />
@livewireScripts
</body>
</html>

View File

@ -118,8 +118,8 @@
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-ink-2 hover:bg-line hover:text-ink">
Bearbeiten
</button>
{{-- R5: wire to wire-elements/modal --}}
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
{{-- R5: destructive wire-elements/modal confirm + audit --}}
<button type="button" wire:click="confirmDelete('{{ $e['name'] }}')" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
Löschen
</button>
</div>

View File

@ -0,0 +1,39 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span @class([
'grid h-10 w-10 shrink-0 place-items-center rounded-md border',
'border-offline/30 bg-offline/10 text-offline' => $danger,
'border-accent/30 bg-accent/10 text-accent-text' => ! $danger,
])>
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ $heading }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ $body }}</p>
@if ($auditTarget)
<p class="mt-2 truncate font-mono text-[11px] text-ink-4">{{ $auditTarget }}</p>
@endif
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-4 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="confirm"
wire:loading.attr="disabled"
@class([
'inline-flex min-h-11 items-center gap-2 rounded-md px-4 font-display text-sm font-semibold uppercase tracking-wide transition-colors disabled:opacity-50',
'bg-offline text-void hover:bg-offline/90' => $danger,
'bg-accent text-void hover:bg-accent-bright' => ! $danger,
])
>
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-4 w-4" />
{{ $confirmLabel }}
</button>
</div>
</div>

View File

@ -161,6 +161,13 @@
</div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div>
{{-- R5: destructive wire-elements/modal confirm + audit --}}
<button type="button"
wire:click="confirmKeyRemoval('{{ $key['fingerprint'] }}', '{{ $key['comment'] }}')"
class="inline-flex min-h-11 shrink-0 items-center rounded-sm px-2 text-ink-3 transition-colors hover:bg-offline/10 hover:text-offline"
title="Schlüssel entfernen">
<x-icon name="trash" class="h-3.5 w-3.5" />
</button>
</div>
@endforeach
</div>

View File

@ -63,19 +63,19 @@
<div class="flex items-center gap-1.5">
<button
type="button"
wire:click="start('{{ $svc['name'] }}')"
wire:click="confirm('start', '{{ $svc['name'] }}')"
@disabled($svc['status'] === 'online')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-online/30 hover:bg-online/10 hover:text-online disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Start</button>
<button
type="button"
wire:click="stop('{{ $svc['name'] }}')"
wire:click="confirm('stop', '{{ $svc['name'] }}')"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-offline/30 hover:bg-offline/10 hover:text-offline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Stopp</button>
<button
type="button"
wire:click="restart('{{ $svc['name'] }}')"
wire:click="confirm('restart', '{{ $svc['name'] }}')"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-accent/25 bg-accent/10 px-3 text-xs text-accent-text transition-colors hover:bg-accent/15 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-accent/10"
>Neustart</button>

View File

@ -0,0 +1,57 @@
<div>
@isset($jsPath)
<script>{!! file_get_contents($jsPath) !!}</script>
@endisset
@isset($cssPath)
<style>{!! file_get_contents($cssPath) !!}</style>
@endisset
<div
x-data="LivewireUIModal()"
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-10 overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-all transform"
>
<div class="absolute inset-0 bg-void/80 backdrop-blur-sm"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
x-show="show && showActiveComponent"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-bind:class="modalWidth"
class="inline-block w-full align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle sm:w-full"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
</div>
@empty
@endforelse
</div>
</div>
</div>
</div>