Replace native confirm() dialogs with the app's own modal pattern
tests / pest (push) Failing after 7m14s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

feat/granted-plans
nexxo 2026-07-28 19:34:27 +02:00
parent c2681f2801
commit 973fcb3f2d
39 changed files with 856 additions and 31 deletions

View File

@ -212,3 +212,58 @@ Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat
zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber
die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den
Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.
---
## R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog
**Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in
einem Fenster, das der Browser zeichnet.**
Verboten:
1. **`wire:confirm`.** Das Attribut ruft `window.confirm()` auf, bevor die
Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin,
kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht
gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
2. **`confirm(` aus eigenem JavaScript.** Derselbe native Dialog, nur ohne die
Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe
Problem unter anderem Namen.
### Warum das aufgeschrieben wurde
Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf
admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich
übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst
durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten
dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu
ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte
Identitäten nach R21), ein Benutzerzugang entzogen.
### Wie es jetzt gebaut ist
- Sechs eigene Bestätigungs-Modals nach dem Muster von `ConfirmRemoveHost` und
den anderen bestehenden Lösch-Bestätigungen — `App\Livewire\Admin\
ConfirmSaveSecret`, `ConfirmForgetSecret`, `ConfirmReissueVpnPeer`,
`ConfirmDisableTwoFactor` in der Konsole; `App\Livewire\
ConfirmDisableTwoFactor` und `ConfirmRevokeSeat` im Portal. Geöffnet über
`$dispatch('openModal', { component: '…', arguments: { … } })`, derselbe
Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen
benannt statt „OK").
- Das Modal mutiert nichts selbst. Der eigentliche Vorgang (`save()`,
`forget()`, `reissue()`, `disableTwoFactor()`, `revoke()`) bleibt
unverändert auf der Seiten-Komponente stehen, mit ihren eigenen
`guard()`-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein
Event aus (z. B. `secret-save-confirmed`), das die Seiten-Komponente per
`#[On(...)]` auffängt und an die bestehende Methode weiterreicht — das hält
die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt
sie im Modal zu verdoppeln.
- Der bisherige Bestätigungssatz bleibt wortgleich als `_body` stehen; nur der
neue `_title` (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam
hinzu, `_confirm` heißt jetzt wie überall sonst im Repo die kurze
Knopfbeschriftung statt eines ganzen Satzes.
### Erzwungen durch
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator's own two-factor login is removed (R23).
* No mutation here TwoFactorSetup::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('two-factor-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-disable-two-factor');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a stored override is removed (R23), reverting the key
* to whatever the server file provides. No mutation here it dispatches
* back to Secrets, whose forget() is unchanged.
*/
class ConfirmForgetSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-forget-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-forget-secret');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a staff access gets a fresh keypair (R23). The reissue
* itself still runs on Vpn::reissue(), unchanged this only gates it and,
* once confirmed, dispatches back so the page can show the new config.
*/
class ConfirmReissueVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $peer);
abort_if($peer->kind !== VpnPeer::KIND_STAFF, 404);
$this->uuid = $uuid;
$this->name = $peer->name;
}
public function confirm(): void
{
$this->dispatch('vpn-peer-reissue-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-reissue-vpn-peer');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a credential takes effect (R23).
*
* The value being saved lives only in Secrets' own (deferred) `entered`
* property this modal never sees it and cannot mutate anything itself.
* Confirming dispatches back to the page component, whose save() keeps its
* own guard() (capability + recent password) unchanged.
*/
class ConfirmSaveSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-save-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-secret');
}
}

View File

@ -7,6 +7,7 @@ use App\Services\Secrets\SecretVault;
use App\Services\Stripe\StripeCheck;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
@ -81,6 +82,17 @@ class Secrets extends Component
$this->dispatch('notify', message: __('secrets.saved'));
}
/**
* The save button opens ConfirmSaveSecret instead of calling save()
* directly (R23) that modal cannot see `entered` itself, so its confirm
* button dispatches back here rather than mutating anything on its own.
*/
#[On('secret-save-confirmed')]
public function onSaveConfirmed(string $key): void
{
$this->save($key);
}
public function forget(string $key): void
{
$this->guard();
@ -90,6 +102,13 @@ class Secrets extends Component
$this->dispatch('notify', message: __('secrets.removed'));
}
/** See onSaveConfirmed() — same reasoning, for ConfirmForgetSecret. */
#[On('secret-forget-confirmed')]
public function onForgetConfirmed(string $key): void
{
$this->forget($key);
}
/**
* Try the key that is in force or the one being typed, before storing it.
*

View File

@ -10,6 +10,7 @@ use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
@ -122,6 +123,17 @@ class TwoFactorSetup extends Component
$this->dispatch('notify', message: __('two_factor_setup.off'));
}
/**
* The disable button opens ConfirmDisableTwoFactor instead of calling
* disableTwoFactor() directly (R23); its confirm button dispatches back
* here, and disableTwoFactor() still runs its own password check.
*/
#[On('two-factor-disable-confirmed')]
public function onDisableConfirmed(): void
{
$this->disableTwoFactor();
}
/**
* Every two-factor action goes through this.
*

View File

@ -225,6 +225,16 @@ class Vpn extends Component
$this->dispatch('notify', message: __('vpn.deleted'));
}
/**
* The reissue button opens ConfirmReissueVpnPeer instead of calling
* reissue() directly (R23); its confirm button dispatches back here.
*/
#[On('vpn-peer-reissue-confirmed')]
public function onReissueConfirmed(string $uuid): void
{
$this->reissue($uuid);
}
/**
* Replace an access's keypair.
*

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a customer's own two-factor sign-in is removed (R23).
* No mutation here Settings::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('twofa-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-disable-two-factor');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a seat's access ends (R23). Mirrors EditSeat: a modal
* is reachable without the page's route middleware, so the seat is resolved
* fresh from the signed-in customer rather than trusted from the caller.
*/
class ConfirmRevokeSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$seat = $this->customer()?->seats()->where('uuid', $uuid)->first();
// Mirrors the button's own visibility: the row never offers "revoke"
// for the owner seat, so a stale or forged uuid gets 404 here too.
abort_if($seat === null || $seat->role === 'owner', 404);
$this->uuid = $uuid;
$this->name = (string) ($seat->name ?: $seat->email);
}
public function confirm(): void
{
$this->dispatch('seat-revoke-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.confirm-revoke-seat');
}
}

View File

@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
@ -74,6 +75,17 @@ class Settings extends Component
$this->dispatch('notify', message: __('settings.twofa_off'));
}
/**
* The disable button opens ConfirmDisableTwoFactor instead of calling
* disableTwoFactor() directly (R23); its confirm button dispatches back
* here, and disableTwoFactor() still runs its own password check.
*/
#[On('twofa-disable-confirmed')]
public function onTwoFaDisableConfirmed(): void
{
$this->disableTwoFactor();
}
/**
* Every two-factor action goes through this.
*

View File

@ -8,6 +8,7 @@ use App\Models\Seat;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -182,6 +183,16 @@ class Users extends Component
}
}
/**
* The revoke button opens ConfirmRevokeSeat instead of calling revoke()
* directly (R23); its confirm button dispatches back here.
*/
#[On('seat-revoke-confirmed')]
public function onRevokeConfirmed(string $uuid): void
{
$this->revoke($uuid);
}
public function resend(string $uuid): void
{
// Invite delivery is mocked for now.

View File

@ -28,9 +28,18 @@ return [
'test' => 'Verbindung testen',
'save' => 'Speichern',
'save_confirm' => 'Diesen Schlüssel wirklich übernehmen? Er wirkt sofort — ein falscher Wert legt Zahlungen still.',
// Modal statt wire:confirm (R23): der Titel nennt, anders als der alte
// Fließtext, welcher der drei Schlüssel gemeint ist — sonst nicht zu
// unterscheiden. Der Fließtext selbst (body) ist unverändert der frühere
// Bestätigungssatz; _confirm ist jetzt die kurze Knopfbeschriftung, wie
// überall sonst im Repo.
'save_title' => ':label wirklich übernehmen?',
'save_body' => 'Er wirkt sofort — ein falscher Wert legt Zahlungen still.',
'save_confirm' => 'Übernehmen',
'forget' => 'Hier entfernen',
'forget_confirm' => 'Hinterlegten Wert entfernen? Danach gilt wieder der Wert aus der Serverdatei — falls dort einer steht.',
'forget_title' => ':label hier entfernen?',
'forget_body' => 'Danach gilt wieder der Wert aus der Serverdatei — falls dort einer steht.',
'forget_confirm' => 'Entfernen',
'saved' => 'Gespeichert. Der neue Wert gilt ab sofort.',
'removed' => 'Entfernt. Es gilt wieder der Wert aus der Serverdatei.',
'empty' => 'Bitte einen Wert eingeben.',

View File

@ -67,7 +67,12 @@ return [
'twofa_code_wrong' => 'Dieser Code stimmt nicht.',
'twofa_on' => 'Zwei-Faktor-Anmeldung ist aktiv.',
'twofa_off' => 'Zwei-Faktor-Anmeldung wurde entfernt.',
'twofa_off_confirm' => 'Zwei-Faktor-Anmeldung wirklich entfernen? Das Konto ist danach nur noch durch das Passwort geschützt.',
// Modal statt wire:confirm (R23): title+body derselbe frühere Satz, nur
// am Fragezeichen geteilt; twofa_off_confirm ist jetzt die kurze
// Knopfbeschriftung.
'twofa_off_title' => 'Zwei-Faktor-Anmeldung wirklich entfernen?',
'twofa_off_body' => 'Das Konto ist danach nur noch durch das Passwort geschützt.',
'twofa_off_confirm' => 'Entfernen',
'twofa_disable' => 'Entfernen',
'twofa_new_codes' => 'Neue Wiederherstellungscodes',
'twofa_codes_title' => 'Wiederherstellungscodes',

View File

@ -14,7 +14,11 @@ return [
'code_wrong' => 'Dieser Code stimmt nicht.',
'on' => 'Zwei-Faktor-Anmeldung ist aktiv.',
'off' => 'Zwei-Faktor-Anmeldung wurde entfernt.',
'off_confirm' => 'Zwei-Faktor-Anmeldung wirklich entfernen? Ihr Konto ist danach nur noch durch das Passwort geschützt.',
// Modal statt wire:confirm (R23): title+body derselbe frühere Satz, nur
// am Fragezeichen geteilt; _confirm ist jetzt die kurze Knopfbeschriftung.
'off_title' => 'Zwei-Faktor-Anmeldung wirklich entfernen?',
'off_body' => 'Ihr Konto ist danach nur noch durch das Passwort geschützt.',
'off_confirm' => 'Entfernen',
'disable' => 'Entfernen',
'new_codes' => 'Neue Wiederherstellungscodes',
'codes_title' => 'Wiederherstellungscodes',

View File

@ -50,7 +50,12 @@ return [
'name_optional' => 'Name (optional)',
// Steht statt eines leeren Feldes: der Inhaber behält seinen eigenen Zugang.
'owner_protected' => 'Geschützt',
'revoke_confirm' => ':name entfernen? Der Zugang endet sofort.',
// Modal statt wire:confirm (R23): title+body derselbe frühere Satz, nur
// am Fragezeichen geteilt; revoke_confirm ist jetzt die kurze
// Knopfbeschriftung.
'revoke_title' => ':name entfernen?',
'revoke_body' => 'Der Zugang endet sofort.',
'revoke_confirm' => 'Entfernen',
'resend' => 'Erneut senden',
'revoke' => 'Entfernen',

View File

@ -78,7 +78,12 @@ return [
'no_stored_config' => 'Für diesen Zugang wurde die Konfiguration nicht gespeichert — der private Schlüssel existiert nur noch auf Ihrem Gerät. Über „Neu ausstellen" bekommen Sie eine neue.',
'reissue' => 'Neu ausstellen',
'reissue_confirm' => 'Für :name ein neues Schlüsselpaar erzeugen? Die bisherige Konfiguration wird sofort ungültig.',
// Modal statt wire:confirm (R23): title+body sind derselbe frühere Satz,
// nur am Fragezeichen geteilt; _confirm ist jetzt die kurze
// Knopfbeschriftung, wie bei delete_confirm oben.
'reissue_title' => 'Für :name ein neues Schlüsselpaar erzeugen?',
'reissue_body' => 'Die bisherige Konfiguration wird sofort ungültig.',
'reissue_confirm' => 'Neu ausstellen',
'reissued' => 'Neuer Schlüssel erzeugt — die alte Konfiguration gilt nicht mehr.',
'reissue_staff_only' => 'Host-Zugänge werden über die Host-Verwaltung erneuert, nicht hier.',

View File

@ -28,9 +28,17 @@ return [
'test' => 'Test connection',
'save' => 'Save',
'save_confirm' => 'Really apply this key? It takes effect immediately — a wrong value stops payments.',
// Modal instead of wire:confirm (R23): the title names which of the
// three keys is meant — the old sentence alone did not. The body is
// unchanged, the former confirmation sentence; _confirm is now the short
// confirm-button label, as everywhere else in the repo.
'save_title' => 'Really apply :label?',
'save_body' => 'It takes effect immediately — a wrong value stops payments.',
'save_confirm' => 'Apply',
'forget' => 'Remove from here',
'forget_confirm' => 'Remove the stored value? The server file applies again — if it has one.',
'forget_title' => 'Remove :label from here?',
'forget_body' => 'The server file applies again — if it has one.',
'forget_confirm' => 'Remove',
'saved' => 'Saved. The new value applies immediately.',
'removed' => 'Removed. The server file applies again.',
'empty' => 'Enter a value.',

View File

@ -67,7 +67,12 @@ return [
'twofa_code_wrong' => 'That code is not right.',
'twofa_on' => 'Two-factor sign-in is active.',
'twofa_off' => 'Two-factor sign-in has been removed.',
'twofa_off_confirm' => 'Really remove two-factor sign-in? The account will then be protected by its password alone.',
// Modal instead of wire:confirm (R23): title+body are the same former
// sentence, split at the full stop; twofa_off_confirm is now the short
// confirm-button label.
'twofa_off_title' => 'Really remove two-factor sign-in?',
'twofa_off_body' => 'The account will then be protected by its password alone.',
'twofa_off_confirm' => 'Remove',
'twofa_disable' => 'Remove',
'twofa_new_codes' => 'New recovery codes',
'twofa_codes_title' => 'Recovery codes',

View File

@ -14,7 +14,12 @@ return [
'code_wrong' => 'That code is not correct.',
'on' => 'Two-factor login is active.',
'off' => 'Two-factor login was removed.',
'off_confirm' => 'Really remove two-factor login? Your account is then protected by the password alone.',
// Modal instead of wire:confirm (R23): title+body are the same former
// sentence, split at the full stop; _confirm is now the short
// confirm-button label.
'off_title' => 'Really remove two-factor login?',
'off_body' => 'Your account is then protected by the password alone.',
'off_confirm' => 'Remove',
'disable' => 'Remove',
'new_codes' => 'New recovery codes',
'codes_title' => 'Recovery codes',

View File

@ -50,7 +50,12 @@ return [
'name_optional' => 'Name (optional)',
// Shown instead of an empty cell: the owner keeps their own access.
'owner_protected' => 'Protected',
'revoke_confirm' => 'Remove :name? Their access ends immediately.',
// Modal instead of wire:confirm (R23): title+body are the same former
// sentence, split at the full stop; revoke_confirm is now the short
// confirm-button label.
'revoke_title' => 'Remove :name?',
'revoke_body' => 'Their access ends immediately.',
'revoke_confirm' => 'Remove',
'resend' => 'Resend',
'revoke' => 'Remove',

View File

@ -78,7 +78,12 @@ return [
'no_stored_config' => 'This access was created without storing its configuration — the private key only exists on your device. Use "Re-issue" to get a new one.',
'reissue' => 'Re-issue',
'reissue_confirm' => 'Generate a new keypair for :name? The current configuration stops working immediately.',
// Modal instead of wire:confirm (R23): title+body are the same former
// sentence, split at the full stop; _confirm is now the short
// confirm-button label, like delete_confirm above.
'reissue_title' => 'Generate a new keypair for :name?',
'reissue_body' => 'The current configuration stops working immediately.',
'reissue_confirm' => 'Re-issue',
'reissued' => 'New key issued — the old configuration no longer works.',
'reissue_staff_only' => 'Host accesses are renewed through host management, not here.',

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('two_factor_setup.off_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('two_factor_setup.off_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
{{ __('two_factor_setup.off_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('secrets.forget_title', ['label' => $label]) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('secrets.forget_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('secrets.forget_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('vpn.reissue_title', ['name' => $name]) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('vpn.reissue_body', ['name' => $name]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('vpn.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="refresh" class="size-4" />{{ __('vpn.reissue_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('secrets.save_title', ['label' => $label]) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('secrets.save_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('secrets.save_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -81,16 +81,14 @@
</x-ui.button>
@endif
<x-ui.button variant="primary" wire:click="save('{{ $entry['key'] }}')"
wire:confirm="{{ __('secrets.save_confirm') }}"
wire:loading.attr="disabled" wire:target="save('{{ $entry['key'] }}')">
<x-ui.button variant="primary"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-save-secret', arguments: { key: '{{ $entry['key'] }}' } })">
{{ __('secrets.save') }}
</x-ui.button>
@if ($entry['source'] === 'stored')
<x-ui.button variant="ghost" wire:click="forget('{{ $entry['key'] }}')"
wire:confirm="{{ __('secrets.forget_confirm') }}"
wire:loading.attr="disabled" wire:target="forget('{{ $entry['key'] }}')">
<x-ui.button variant="ghost"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-forget-secret', arguments: { key: '{{ $entry['key'] }}' } })">
{{ __('secrets.forget') }}
</x-ui.button>
@endif

View File

@ -34,9 +34,8 @@
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('two_factor_setup.new_codes') }}
</x-ui.button>
<x-ui.button variant="danger" wire:click="disableTwoFactor"
wire:confirm="{{ __('two_factor_setup.off_confirm') }}"
wire:loading.attr="disabled" wire:target="disableTwoFactor">
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-disable-two-factor' })">
{{ __('two_factor_setup.disable') }}
</x-ui.button>
</div>

View File

@ -167,8 +167,8 @@
@endcan
@can('update', $peer)
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
<button type="button" wire:click="reissue('{{ $peer->uuid }}')"
wire:confirm="{{ __('vpn.reissue_confirm', ['name' => $peer->name]) }}"
<button type="button"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-reissue-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="refresh" class="size-4" />

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('settings.twofa_off_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('settings.twofa_off_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('settings.keep') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
{{ __('settings.twofa_off_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('users.revoke_title', ['name' => $name]) }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('users.revoke_body', ['name' => $name]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirm" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('users.revoke_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -87,9 +87,8 @@
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
{{ __('settings.twofa_new_codes') }}
</x-ui.button>
<x-ui.button variant="danger" wire:click="disableTwoFactor"
wire:confirm="{{ __('settings.twofa_off_confirm') }}"
wire:loading.attr="disabled" wire:target="disableTwoFactor">
<x-ui.button variant="danger"
x-on:click="$dispatch('openModal', { component: 'confirm-disable-two-factor' })">
{{ __('settings.twofa_disable') }}
</x-ui.button>
</div>

View File

@ -105,8 +105,8 @@
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-warning hover:text-warning">
<x-ui.icon :name="$seat->status === 'suspended' ? 'unlock' : 'lock'" class="size-4" />
</button>
<button type="button" wire:click="revoke('{{ $seat->uuid }}')"
wire:confirm="{{ __('users.revoke_confirm', ['name' => $seat->name ?: $seat->email]) }}"
<button type="button"
x-on:click="$dispatch('openModal', { component: 'confirm-revoke-seat', arguments: { uuid: '{{ $seat->uuid }}' } })"
aria-label="{{ __('users.revoke') }}" title="{{ __('users.revoke') }}"
class="grid size-9 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />

View File

@ -1,5 +1,6 @@
<?php
use App\Livewire\Admin\ConfirmDisableTwoFactor;
use App\Livewire\Admin\TwoFactorSetup;
use Illuminate\Support\Facades\Auth;
use Livewire\Livewire;
@ -32,7 +33,10 @@ use PragmaRX\Google2FA\Google2FA;
it('refuses every two-factor action without a recent password confirmation', function () {
$op = operator('Support');
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
// onDisableConfirmed is the R23 forwarding listener ConfirmDisableTwoFactor
// reaches — it must lead straight into the same guard as disableTwoFactor,
// not around it.
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes', 'onDisableConfirmed'] as $action) {
Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->call($action)
@ -156,6 +160,48 @@ it('lets an operator regenerate recovery codes and disable two-factor once confi
->and($op->two_factor_secret)->toBeNull();
});
it('confirms disabling two-factor through the modal without turning it off itself', function () {
// R23: the modal takes no arguments and cannot see whose session this is
// — it only dispatches back to the page, which still runs
// disableTwoFactor() with its own password check (see the "refuses every
// two-factor action" case above, which now covers onDisableConfirmed too).
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
Livewire::actingAs($op, 'operator')
->test(ConfirmDisableTwoFactor::class)
->call('confirm')
->assertDispatched('two-factor-disable-confirmed');
expect($op->refresh()->two_factor_confirmed_at)->not->toBeNull();
});
it('disables two-factor once the page receives the confirmed event', function () {
$op = operator('Support');
$page = Livewire::actingAs($op, 'operator')
->test(TwoFactorSetup::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
$page->call('onDisableConfirmed')->assertHasNoErrors();
expect($op->refresh()->two_factor_confirmed_at)->toBeNull()
->and($op->two_factor_secret)->toBeNull();
});
it('sends the operator to sign in instead of a 500 when the session has ended', function () {
// render() has no requireConfirmedPassword() gate in front of it — unlike
// every action above, Livewire calls it on every update including a bare

View File

@ -1,5 +1,7 @@
<?php
use App\Livewire\Admin\ConfirmForgetSecret;
use App\Livewire\Admin\ConfirmSaveSecret;
use App\Livewire\Admin\Secrets;
use App\Livewire\Admin\Secrets as SecretsPage;
use App\Services\Secrets\SecretVault;
@ -109,6 +111,66 @@ it('can be locked again without signing out', function () {
->assertViewHas('unlocked', false);
});
// R23: confirmation moved from wire:confirm to ConfirmSaveSecret /
// ConfirmForgetSecret. Neither modal can see Secrets' own (deferred)
// `entered` state, so they dispatch back to the page instead of mutating
// anything themselves — save()/forget() keep their existing guard().
it('does not open the save/forget confirmation without the capability', function () {
Livewire::actingAs(operator('Admin'), 'operator')
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
->assertForbidden();
Livewire::actingAs(operator('Admin'), 'operator')
->test(ConfirmForgetSecret::class, ['key' => 'stripe.secret'])
->assertForbidden();
});
it('confirms a save through the modal without storing anything itself', function () {
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(ConfirmSaveSecret::class, ['key' => 'stripe.secret'])
->assertSee(__('secrets.item.stripe_secret'))
->call('confirm')
->assertDispatched('secret-save-confirmed', key: 'stripe.secret');
// The modal only asked; nothing is stored until the page itself saves.
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});
it('saves the typed value once the page receives the confirmed event', function () {
// Exercises the forwarding listener directly — the same round trip a
// browser produces by dispatching openModal then confirm (checked
// manually against admin.dev.clupilot.com, since a global browser event
// reaching a sibling component's #[On] listener is not something a
// single-component Livewire::test() can simulate).
$owner = operator('Owner');
Livewire::actingAs($owner, 'operator')
->test(SecretsPage::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->set('entered.stripe_secret', 'sk_live_VIACONFIRM1')
->call('onSaveConfirmed', 'stripe.secret')
->assertHasNoErrors();
expect(app(SecretVault::class)->get('stripe.secret'))->toBe('sk_live_VIACONFIRM1');
});
it('forgets the stored value once the page receives the confirmed event', function () {
$owner = operator('Owner');
app(SecretVault::class)->put('stripe.secret', 'sk_live_TOFORGET999', $owner);
Livewire::actingAs($owner, 'operator')
->test(SecretsPage::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('onForgetConfirmed', 'stripe.secret');
expect(app(SecretVault::class)->has('stripe.secret'))->toBeFalse();
});
it('binds the form to a key Livewire can actually write to', function () {
// A dot in a Livewire property path means nesting, so a registry key with a
// dot in it would be written to entered['stripe']['secret'] and the value

View File

@ -1,6 +1,7 @@
<?php
use App\Livewire\Admin\ConfirmDeleteVpnPeer;
use App\Livewire\Admin\ConfirmReissueVpnPeer;
use App\Livewire\Admin\Vpn;
use App\Livewire\Admin\VpnConfigAccess;
use App\Models\Host;
@ -828,6 +829,60 @@ it('re-issues a key for an access whose config was never stored', function () {
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && $job->enabled);
});
// R23: confirmation moved from wire:confirm to ConfirmReissueVpnPeer, which
// mirrors ConfirmDeleteVpnPeer above but dispatches back to Vpn::reissue()
// instead of mutating the peer itself, so the page can still show the new
// config after the modal closes.
it('does not open the reissue confirmation without vpn.manage.all', function () {
vpnHub();
$peer = VpnPeer::factory()->ownedBy(operator('Support'))->create();
Livewire::actingAs(operator('Support'), 'operator')
->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
->assertForbidden();
});
it('does not offer the reissue confirmation for a host access', function () {
vpnHub();
$host = Host::factory()->active()->create();
$peer = VpnPeer::factory()->forHost()->create(['host_id' => $host->id]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
->assertNotFound();
});
it('confirms a reissue through the modal without changing the key itself', function () {
vpnHub();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create(['name' => 'Notebook-Boban']);
$oldKey = $peer->public_key;
Livewire::actingAs($owner, 'operator')
->test(ConfirmReissueVpnPeer::class, ['uuid' => $peer->uuid])
->assertSee('Notebook-Boban')
->call('confirm')
->assertDispatched('vpn-peer-reissue-confirmed', uuid: $peer->uuid);
expect($peer->fresh()->public_key)->toBe($oldKey);
});
it('re-issues once the page receives the confirmed event', function () {
vpnHub();
Queue::fake();
$owner = operator('Owner');
$peer = VpnPeer::factory()->ownedBy($owner)->create();
$oldKey = $peer->public_key;
// Exercises the forwarding listener directly — see the equivalent note in
// SecretsPageTest for why the cross-component event round trip itself is
// checked manually rather than in a single-component Livewire::test().
Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('onReissueConfirmed', $peer->uuid);
expect($peer->fresh()->public_key)->not->toBe($oldKey);
});
it('keeps a stored config in step when the key is re-issued', function () {
vpnHub();
Queue::fake();

View File

@ -0,0 +1,63 @@
<?php
use Illuminate\Support\Facades\File;
/**
* R23 confirmation happens in a modal, never in a native browser dialog.
*
* The operator console popped a real `window.confirm()` before saving a
* Stripe key a system window with the hostname in it, no part of the
* interface this product draws, and not translatable beyond whatever
* language the browser happens to be in. `wire:confirm` is exactly that
* dialog with a Livewire attribute on it; a bare `confirm(` call from
* hand-written JavaScript is the same dialog under a different name. Both are
* forbidden repo-wide the fix is the six confirm-* modals under
* app/Livewire and app/Livewire/Admin, not a one-off patch where it was
* first noticed.
*/
it('has no wire:confirm attribute anywhere in the repo', function () {
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
if (str_contains($file->getContents(), 'wire:confirm')) {
$offenders[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
expect($offenders)->toBe([]);
});
it('never calls the native confirm() dialog from JavaScript', function () {
$offenders = [];
// Blade views (inline <script> blocks) and the app's own JavaScript —
// not vendor/node_modules, which ship code this repo does not author.
$roots = [resource_path('views'), resource_path('js')];
foreach ($roots as $root) {
if (! File::isDirectory($root)) {
continue;
}
foreach (File::allFiles($root) as $file) {
$name = $file->getFilename();
if (! str_ends_with($name, '.blade.php') && ! str_ends_with($name, '.js')) {
continue;
}
// window.confirm(...) too: the word boundary catches both, and a
// PHP method like confirmPassword() has no "(" right after
// "confirm" so it never matches.
if (preg_match('/\bconfirm\s*\(/', $file->getContents()) === 1) {
$offenders[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
}
expect($offenders)->toBe([]);
});

View File

@ -1,8 +1,10 @@
<?php
use App\Livewire\ConfirmDisableTwoFactor;
use App\Livewire\Settings as PortalSettings;
use App\Models\Customer;
use Livewire\Livewire;
use PragmaRX\Google2FA\Google2FA;
/**
* Customers setting up two-factor themselves.
@ -25,7 +27,10 @@ function portalUser(): App\Models\User
it('refuses every two-factor action without a recent password confirmation', function () {
$user = portalUser();
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
// onTwoFaDisableConfirmed is the R23 forwarding listener
// ConfirmDisableTwoFactor reaches — it must lead straight into the same
// guard as disableTwoFactor, not around it.
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes', 'onTwoFaDisableConfirmed'] as $action) {
Livewire::actingAs($user)
->test(PortalSettings::class)
->call($action)
@ -50,7 +55,7 @@ it('takes a password, then sets two-factor up and confirms it with a real code',
// closes the tab must not be locked out of their own account.
->and($user->two_factor_confirmed_at)->toBeNull();
$code = app(PragmaRX\Google2FA\Google2FA::class)->getCurrentOtp(decrypt($user->two_factor_secret));
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
@ -101,3 +106,45 @@ it('never puts the secret where the browser can see it', function () {
expect(json_encode($page->snapshot))->not->toContain($secret);
});
// R23: confirmation moved from wire:confirm to ConfirmDisableTwoFactor, which
// dispatches back to Settings::disableTwoFactor() instead of mutating
// anything itself.
it('confirms disabling two-factor through the modal without turning it off itself', function () {
$user = portalUser();
$page = Livewire::actingAs($user)
->test(PortalSettings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
Livewire::actingAs($user)
->test(ConfirmDisableTwoFactor::class)
->call('confirm')
->assertDispatched('twofa-disable-confirmed');
expect($user->refresh()->two_factor_confirmed_at)->not->toBeNull();
});
it('disables two-factor once the page receives the confirmed event', function () {
$user = portalUser();
$page = Livewire::actingAs($user)
->test(PortalSettings::class)
->set('confirmablePassword', 'password')
->call('confirmPassword')
->call('enableTwoFactor');
$code = app(Google2FA::class)->getCurrentOtp(decrypt($user->refresh()->two_factor_secret));
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
$page->call('onTwoFaDisableConfirmed')->assertHasNoErrors();
expect($user->refresh()->two_factor_confirmed_at)->toBeNull()
->and($user->two_factor_secret)->toBeNull();
});

View File

@ -1,5 +1,6 @@
<?php
use App\Livewire\ConfirmRevokeSeat;
use App\Livewire\EditSeat;
use App\Livewire\Users;
use App\Models\Customer;
@ -178,3 +179,70 @@ it('opens editing in a modal rather than in the row', function () {
->assertSee('edit-seat', escape: false)
->assertDontSee('wire:model="editName"', escape: false);
});
// R23: confirmation moved from wire:confirm to ConfirmRevokeSeat, which
// mirrors EditSeat above (a modal is reachable without the page's route
// middleware, so the seat is resolved fresh from the signed-in customer) but
// dispatches back to Users::revoke() instead of mutating anything itself.
it('opens the revoke confirmation in a modal too', function () {
// The revoke button only renders for a non-owner seat (the owner row has
// no destructive actions at all), so one has to exist first.
['user' => $user, 'customer' => $customer] = seatSetup();
Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)
->test(Users::class)
->assertSee('confirm-revoke-seat', escape: false);
});
it('refuses to open the revoke confirmation for the owner seat', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
Livewire::actingAs($user)->test(Users::class); // creates owner
$owner = $customer->seats()->where('role', 'owner')->first();
Livewire::actingAs($user)
->test(ConfirmRevokeSeat::class, ['uuid' => $owner->uuid])
->assertStatus(404);
expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue();
});
it('refuses to open the revoke confirmation for somebody else\'s seat', function () {
$mine = User::factory()->create();
Customer::factory()->create(['user_id' => $mine->id, 'email' => $mine->email]);
$theirs = Customer::factory()->create(['user_id' => User::factory()->create()->id]);
$seat = $theirs->seats()->create(['email' => 'x@example.test', 'name' => 'X', 'role' => 'member', 'status' => 'active']);
Livewire::actingAs($mine)
->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid])
->assertStatus(404);
expect($seat->fresh()->name)->toBe('X');
});
it('confirms a revoke through the modal without removing the seat itself', function () {
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member', 'name' => 'Weg Damit']);
Livewire::actingAs($user)
->test(ConfirmRevokeSeat::class, ['uuid' => $seat->uuid])
->assertSee('Weg Damit')
->call('confirm')
->assertDispatched('seat-revoke-confirmed', uuid: $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue();
});
it('revokes the seat once the page receives the confirmed event', function () {
// Exercises the forwarding listener directly — see the equivalent note in
// SecretsPageTest for why the cross-component event round trip itself is
// checked manually rather than in a single-component Livewire::test().
['user' => $user, 'customer' => $customer] = seatSetup();
$seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']);
Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid);
expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse();
});