122 lines
4.2 KiB
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');
|
|
}
|
|
}
|