Replace native confirm() dialogs with the app's own modal pattern
parent
c2681f2801
commit
973fcb3f2d
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue