clusev/app/Livewire/Modals/ConfirmAction.php

122 lines
4.2 KiB
PHP

<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use LivewireUI\Modal\ModalComponent;
/**
* Generic R5 confirmation dialog for destructive / stateful actions.
*
* The opener mints a signed, single-use {@see ConfirmToken} that seals the follow-up
* event + its params + the audit descriptor, and passes ONLY that token (plus display
* copy) to the modal. On confirm we re-verify the token, persist exactly one
* AuditEvent from the SEALED descriptor (never from client input), and re-dispatch the
* sealed event so the originating page can apply its own state change — handing the
* same token on so the apply handler can burn it (single-use). The modal stays
* domain-agnostic and is reused for every confirm in the app (R5).
*
* Routing lives only in the token: the modal exposes no client-mutable event/params/
* audit fields, and the token itself is #[Locked] so it cannot be swapped after open.
*/
class ConfirmAction extends ModalComponent
{
public string $heading = '';
public string $body = '';
public string $confirmLabel = '';
public bool $danger = false;
public string $icon = '';
/** Toast shown on confirm. Pass '' to defer the notification to the handler
* (e.g. when the real outcome is only known after a remote command runs);
* leave null to fall back to the generic default toast. */
public ?string $notify = null;
/** The sealed routing input — the ONLY thing that decides what runs/gets audited. */
#[Locked]
public string $token = '';
public function mount(
string $token,
string $heading = '',
string $body = '',
string $confirmLabel = '',
bool $danger = false,
string $icon = '',
?string $notify = null,
): void {
$this->token = $token;
$this->body = $body;
$this->danger = $danger;
$this->icon = $icon;
// Localized fallbacks for any copy the opener did not pass.
$this->heading = $heading !== '' ? $heading : __('modals.confirm_action.default_heading');
$this->confirmLabel = $confirmLabel !== '' ? $confirmLabel : __('common.confirm');
$this->notify = $notify ?? __('modals.confirm_action.default_notify');
}
public static function modalMaxWidth(): string
{
return 'md';
}
/** Audit-target line for the view — read from the signed token, not client state. */
#[Computed]
public function targetLabel(): ?string
{
return ConfirmToken::peek($this->token)['auditTarget'] ?? null;
}
public function confirm(): void
{
try {
// Flip the token pending → confirmed (single transition). This IS the
// confirmation: an apply handler may only consume a confirmed token.
$payload = ConfirmToken::confirm($this->token);
} catch (InvalidConfirmToken) {
// Forged / tampered / expired / already-confirmed — never audit or dispatch.
$this->dispatch('notify', message: __('modals.confirm_action.rejected'));
$this->closeModal();
return;
}
if (($payload['auditAction'] ?? '') !== '') {
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $payload['serverId'] ?? null,
'actor' => Auth::user()?->name ?? 'system',
'action' => $payload['auditAction'],
'target' => $payload['auditTarget'] ?? null,
'ip' => request()->ip(),
]);
}
// Re-dispatch the sealed event, handing the token on so the apply handler can
// burn it (single-use) and read the sealed params itself.
$this->dispatch($payload['event'], confirmToken: $this->token);
// Empty notify = the handler will report the real outcome itself.
if ($this->notify !== '') {
$this->dispatch('notify', message: $this->notify);
}
$this->closeModal();
}
public function render()
{
return view('livewire.modals.confirm-action');
}
}