fix(modal)+ux: panel above backdrop, one <x-btn> style, SSH credential form

The modal panel rendered *behind* its own backdrop (it "appeared then vanished"):
Tailwind v4 no longer turns `transform` into a stacking context, so the static
panel sat under the fixed/blurred backdrop. Fixed with `relative z-10` on the
panel (interactively verified by force-opening the modal).

- New <x-btn> component: one compact, consistent style (h-8 text-xs) with
  variants (primary/accent/secondary/danger/ghost/ghost-danger). Replaced every
  ad-hoc action button — modals, service start/stop/restart, file row actions +
  upload, server-details (Zugang/Dateien/key add+remove). No more oversized,
  inconsistent buttons.
- New EditCredential form modal + "Zugang" button on Server-Details: deposit or
  update a server's SSH login (e.g. root) — this is where the privileged
  credential for systemctl/journal gets stored (encrypted vault). Changing it
  re-pulls the snapshot with the new login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 23:32:28 +02:00
parent 94972c78bd
commit 5479896bf3
10 changed files with 199 additions and 81 deletions

View File

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: set/update a server's SSH credential (encrypted vault). This is
* where an operator deposits e.g. a root/sudo login to enable service control.
*/
class EditCredential extends ModalComponent
{
public int $serverId;
public string $username = '';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
public ?string $error = null;
public function mount(int $serverId): void
{
$this->serverId = $serverId;
if ($cred = Server::find($serverId)?->credential) {
$this->username = $cred->username;
$this->authType = $cred->auth_type;
// secret/passphrase are never pre-filled (encrypted at rest)
}
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function save(): void
{
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
return;
}
if (trim($this->username) === '' || trim($this->secret) === '') {
$this->error = 'Benutzer und Passwort/Schlüssel sind erforderlich.';
return;
}
$server->credential()->updateOrCreate([], [
'username' => trim($this->username),
'auth_type' => $this->authType === 'key' ? 'key' : 'password',
'secret' => $this->secret,
'passphrase' => $this->passphrase !== '' ? $this->passphrase : null,
]);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'server.credential_updated',
'target' => $server->name.' · '.trim($this->username),
'ip' => request()->ip(),
]);
$this->dispatch('credentialChanged');
$this->dispatch('notify', message: 'Zugangsdaten gespeichert.');
$this->closeModal();
}
public function render()
{
return view('livewire.modals.edit-credential');
}
}

View File

@ -138,6 +138,15 @@ class Show extends Component
}
}
/** Credential changed -> reconnect with the new login and re-pull the snapshot. */
#[On('credentialChanged')]
public function reloadAfterCredential(FleetService $fleet): void
{
$this->server->refresh();
$this->ready = false;
$this->load($fleet);
}
/** Open the file manager scoped to this server. */
public function openFiles()
{

View File

@ -0,0 +1,17 @@
@props(['variant' => 'secondary', 'icon' => false])
@php
// One button style for the whole app — compact, consistent (R10).
$variants = [
'primary' => 'bg-accent text-void hover:bg-accent-bright',
'accent' => 'border border-accent/25 bg-accent/10 text-accent-text hover:bg-accent/15',
'secondary' => 'border border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink',
'danger' => 'bg-offline text-void hover:bg-offline/90',
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink',
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
];
$shape = $icon ? 'h-8 w-8 justify-center' : 'h-8 px-3';
$classes = 'inline-flex items-center gap-1.5 rounded-md text-xs font-medium transition-colors '
.'disabled:opacity-50 disabled:pointer-events-none '
.$shape.' '.($variants[$variant] ?? $variants['secondary']);
@endphp
<button {{ $attributes->merge(['type' => 'button', 'class' => $classes]) }}>{{ $slot }}</button>

View File

@ -49,9 +49,9 @@
{{-- Listing --}}
<x-panel :title="$path" :subtitle="$dirs . ' Ordner · ' . $files . ' Dateien'" :padded="false">
<x-slot:actions>
<button type="button" class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-btn variant="accent">
<x-icon name="plus" class="h-3.5 w-3.5" /> Hochladen
</button>
</x-btn>
</x-slot:actions>
{{-- Column header (desktop only) --}}
@ -116,19 +116,10 @@
{{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
{{-- 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-ink-2 hover:bg-line hover:text-ink">
Download
</button>
<x-btn variant="ghost">Download</x-btn>
@endunless
{{-- 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-ink-2 hover:bg-line hover:text-ink">
Bearbeiten
</button>
{{-- R5: destructive wire-elements/modal confirm + audit --}}
<button type="button" wire:click="confirmDelete(@js($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>
<x-btn variant="ghost">Bearbeiten</x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDelete(@js($e['name']))">Löschen</x-btn>
</div>
</div>
@endforeach

View File

@ -24,21 +24,11 @@
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex h-9 items-center rounded-md border border-line bg-inset px-3.5 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="save"
wire:target="save"
wire:loading.attr="disabled"
class="inline-flex h-9 items-center gap-2 rounded-md bg-accent px-3.5 text-sm font-medium text-void transition-colors hover:bg-accent-bright disabled:opacity-60"
>
<x-icon name="plus" class="h-4 w-4" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-4 w-4 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 variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" 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>
Hinzufügen
</button>
</x-btn>
</div>
</div>

View File

@ -17,25 +17,11 @@
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button
type="button"
wire:click="$dispatch('closeModal')"
class="inline-flex h-9 items-center rounded-md border border-line bg-inset px-3.5 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
>Abbrechen</button>
<button
type="button"
wire:click="confirm"
wire:target="confirm"
wire:loading.attr="disabled"
@class([
'inline-flex h-9 items-center gap-2 rounded-md px-3.5 text-sm font-medium transition-colors disabled:opacity-60',
'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" wire:loading.remove wire:target="confirm" />
<svg wire:loading wire:target="confirm" class="h-4 w-4 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 variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn :variant="$danger ? 'danger' : 'primary'" wire:click="confirm" wire:target="confirm" wire:loading.attr="disabled">
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-3.5 w-3.5" wire:loading.remove wire:target="confirm" />
<svg wire:loading wire:target="confirm" 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>
{{ $confirmLabel }}
</button>
</x-btn>
</div>
</div>

View File

@ -0,0 +1,59 @@
<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-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">SSH-Zugang</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">Hier den Zugang hinterlegen z. B. <span class="font-mono text-ink-3">root</span> für volle Steuerung (systemctl, Journal).</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Benutzer</label>
<input wire:model="username" type="text" placeholder="root"
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" />
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Authentifizierung</label>
<select wire:model.live="authType"
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
<option value="password">Passwort</option>
<option value="key">Privater Schlüssel</option>
</select>
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? 'Privater Schlüssel (PEM)' : 'Passwort' }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@else
<input wire:model="secret" type="password" 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" />
@endif
</div>
@if ($authType === 'key')
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passphrase (optional)</label>
<input wire:model="passphrase" type="password" placeholder="leer = keine"
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" />
</div>
@endif
@if ($error)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-offline">
<x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $error }}
</p>
@endif
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Abbrechen</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" 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>
Speichern
</x-btn>
</div>
</div>

View File

@ -43,11 +43,12 @@
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button type="button" wire:click="openFiles"
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink">
<x-icon name="folder" class="h-3.5 w-3.5" />
Dateien
</button>
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.edit-credential', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="shield" class="h-3.5 w-3.5" /> Zugang
</x-btn>
<x-btn variant="secondary" wire:click="openFiles">
<x-icon name="folder" class="h-3.5 w-3.5" /> Dateien
</x-btn>
<x-status-pill :status="$server->status">{{ $statusLabel }}</x-status-pill>
</div>
</div>
@ -157,12 +158,10 @@
<x-panel title="SSH-Schlüssel" :subtitle="count($sshKeys) . ' autorisiert'" :padded="false">
<x-slot:actions>
{{-- R5: wire to wire-elements/modal --}}
<button type="button"
wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })"
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-btn variant="accent" wire:click="$dispatch('openModal', { component: 'modals.add-ssh-key', arguments: { serverId: {{ $server->id }} } })">
<x-icon name="plus" class="h-3.5 w-3.5" />
Schlüssel hinzufügen
</button>
</x-btn>
</x-slot:actions>
<div class="divide-y divide-line">
@foreach ($sshKeys as $key)
@ -178,12 +177,9 @@
<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(@js($key['fingerprint']), @js($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-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval(@js($key['fingerprint']), @js($key['comment']))" title="Schlüssel entfernen">
<x-icon name="trash" class="h-3.5 w-3.5" />
</button>
</x-btn>
</div>
@endforeach
</div>

View File

@ -70,24 +70,9 @@
{{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5">
<button
type="button"
wire:click="confirm('start', @js($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"
>Starten</button>
<button
type="button"
wire:click="confirm('stop', @js($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="confirm('restart', @js($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>
<x-btn variant="secondary" wire:click="confirm('start', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'online')>Starten</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'offline')>Stopp</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', @js($svc['name']))" wire:loading.attr="disabled" @disabled($svc['status'] === 'offline')>Neustart</x-btn>
</div>
</div>
</div>

View File

@ -39,7 +39,7 @@
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"
class="inline-block w-full max-w-lg align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle"
class="relative z-10 inline-block w-full max-w-lg align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"