Move operator two-factor enrolment off admin.settings onto its own page
RequireOperatorTwoFactor exempted the whole of admin.settings while compulsory two-factor was on, on the theory that it was "the page where two-factor is set up". That component also carries staff management, site visibility, network restrictions, account changes, and the two-factor policy switch itself, so an unenrolled operator got unrestricted access to all of that, not just enrolment. Enrolment (secret, QR, confirm, recovery codes, regenerate, disable) moves to its own route and component, admin.two-factor-setup / Admin\TwoFactorSetup, reachable by every operator regardless of capability. That is now the only page the middleware exempts besides logout, and its redirect points there instead. admin.settings goes back behind the policy like every other console page; only the policy switch itself stays there, since only someone who already satisfies it should be the one changing it.feat/operator-identity
parent
9c851b28b4
commit
27292237c3
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AdminArea;
|
||||
use App\Support\Settings;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -11,9 +12,18 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
/**
|
||||
* Two-factor for operators, when the owner has made it compulsory.
|
||||
*
|
||||
* Off by default. The settings page itself is exempt, because it is where the
|
||||
* operator sets two-factor up — gating it would leave nobody able to satisfy
|
||||
* the requirement.
|
||||
* Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is
|
||||
* where the operator sets two-factor up — gating it would leave nobody able
|
||||
* to satisfy the requirement. Nothing else is.
|
||||
*
|
||||
* This used to exempt the whole of admin.settings, on the theory that it was
|
||||
* "the page where two-factor is set up". That component also carries staff
|
||||
* management, site visibility, network restrictions, account changes, and
|
||||
* the two-factor policy switch itself — so an unenrolled operator got
|
||||
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
|
||||
* Enrolment has its own route and component for exactly this reason: one
|
||||
* page to exempt is one decision, not eight actions each needing to
|
||||
* remember to check.
|
||||
*/
|
||||
class RequireOperatorTwoFactor
|
||||
{
|
||||
|
|
@ -29,12 +39,13 @@ class RequireOperatorTwoFactor
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// The one page that must stay reachable: where two-factor is enrolled.
|
||||
if (\App\Support\AdminArea::routeIs('admin.settings') || $request->routeIs('admin.logout')) {
|
||||
// The two pages that must stay reachable: where two-factor is
|
||||
// enrolled, and signing out.
|
||||
if (AdminArea::routeIs('admin.two-factor-setup') || $request->routeIs('admin.logout')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.settings')
|
||||
return redirect()->route('admin.two-factor-setup')
|
||||
->with('status', __('admin_settings.two_factor_required'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace App\Livewire\Admin;
|
|||
|
||||
use App\Http\Middleware\RestrictConsoleNetwork;
|
||||
use App\Livewire\Concerns\ChangesOwnPassword;
|
||||
use App\Livewire\Concerns\ConfirmsPassword;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Operator;
|
||||
use App\Models\VpnPeer;
|
||||
|
|
@ -15,11 +14,6 @@ use Illuminate\Support\Facades\Auth;
|
|||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -30,12 +24,16 @@ use Symfony\Component\HttpFoundation\IpUtils;
|
|||
* Operator settings: the signed-in operator's own account plus (Owner-only)
|
||||
* staff management. All staff mutations are capability-gated server-side and
|
||||
* protect the last-Owner and self-role invariants transactionally.
|
||||
*
|
||||
* Two-factor ENROLMENT lives on its own page, Admin\TwoFactorSetup — see
|
||||
* RequireOperatorTwoFactor for why. Only the POLICY switch
|
||||
* (saveTwoFactorPolicy(), below) stays here: only someone who already
|
||||
* satisfies the policy should be the one changing it.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Settings extends Component
|
||||
{
|
||||
use ChangesOwnPassword;
|
||||
use ConfirmsPassword;
|
||||
|
||||
/** This page changes the signed-in OPERATOR's password, never a portal one. */
|
||||
protected function passwordAccountGuard(): string
|
||||
|
|
@ -43,87 +41,6 @@ class Settings extends Component
|
|||
return 'operator';
|
||||
}
|
||||
|
||||
/** Two-factor is confirmed against the operator's own record, not a portal one. */
|
||||
protected function confirmationGuard(): string
|
||||
{
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
/** Shown once, right after setting two-factor up. */
|
||||
public ?array $recoveryCodes = null;
|
||||
|
||||
public string $twoFactorCode = '';
|
||||
|
||||
/**
|
||||
* Start two-factor setup: generate the secret, then show the QR code.
|
||||
*
|
||||
* Not confirmed yet — Fortify keeps `two_factor_confirmed_at` null until a
|
||||
* code from the app has been accepted, so someone who scans nothing and
|
||||
* closes the tab is not left locked out of their own account.
|
||||
*/
|
||||
public function enableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(EnableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
||||
}
|
||||
|
||||
/** Accept a code from the app, which is what actually turns it on. */
|
||||
public function confirmTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
|
||||
try {
|
||||
app(ConfirmTwoFactorAuthentication::class)(
|
||||
$operator,
|
||||
$this->twoFactorCode,
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('admin_settings.twofa_code_wrong'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->twoFactorCode = '';
|
||||
// Shown once, here, because this is the only moment they exist in a
|
||||
// form anyone will read. They stay retrievable afterwards, but nobody
|
||||
// writes down what they were not shown.
|
||||
$this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true);
|
||||
$this->dispatch('notify', message: __('admin_settings.twofa_on'));
|
||||
}
|
||||
|
||||
public function regenerateRecoveryCodes(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
app(GenerateNewRecoveryCodes::class)($operator);
|
||||
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
|
||||
}
|
||||
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(DisableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
||||
$this->recoveryCodes = null;
|
||||
$this->dispatch('notify', message: __('admin_settings.twofa_off'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every two-factor action goes through this.
|
||||
*
|
||||
* Server-side, on each call, and not merely by hiding the buttons: a
|
||||
* Livewire action is reachable by anyone who can post to /livewire/update,
|
||||
* and "the form was not on screen" has never stopped anybody.
|
||||
*/
|
||||
private function requireConfirmedPassword(): void
|
||||
{
|
||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
}
|
||||
|
||||
// My account
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
|
@ -467,8 +384,6 @@ class Settings extends Component
|
|||
|
||||
public function render()
|
||||
{
|
||||
$operator = Auth::guard('operator')->user();
|
||||
|
||||
$staff = Operator::query()
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES))
|
||||
->with('roles')
|
||||
|
|
@ -502,16 +417,6 @@ class Settings extends Component
|
|||
'roles' => Operator::OPERATOR_ROLES,
|
||||
'canManageStaff' => Auth::guard('operator')->user()->can('staff.manage'),
|
||||
'isOwner' => Auth::guard('operator')->user()->hasRole('Owner'),
|
||||
// My own two-factor. Never the secret itself — only whether it
|
||||
// exists, and the SVG Fortify renders from it. The secret in a
|
||||
// Livewire property would travel to the browser and back in the
|
||||
// component snapshot.
|
||||
'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null,
|
||||
'twoFactorOn' => $operator->two_factor_confirmed_at !== null,
|
||||
'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null
|
||||
? $operator->twoFactorQrCodeSvg()
|
||||
: null,
|
||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\ConfirmsPassword;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Where an operator sets their own two-factor login up.
|
||||
*
|
||||
* Its own page, not a card on Admin\Settings. RequireOperatorTwoFactor must
|
||||
* be able to exempt ENROLMENT alone while compulsory two-factor is on — the
|
||||
* earlier shape exempted the whole of Admin\Settings on the theory that it
|
||||
* was "the page where two-factor is set up", but that component also carries
|
||||
* staff management, site visibility, network restrictions, account changes,
|
||||
* and the two-factor policy switch itself. An unenrolled operator got
|
||||
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
|
||||
* One route to exempt is one decision; eight actions on a shared page each
|
||||
* remembering to check is eight chances to forget one.
|
||||
*
|
||||
* Reachable by EVERY operator, not gated behind any capability — the
|
||||
* compulsory switch (Admin\Settings::saveTwoFactorPolicy()) applies to every
|
||||
* member of staff, so enrolment has to be reachable by every operator too.
|
||||
*
|
||||
* Every action is re-checked, server-side, against a recent password
|
||||
* confirmation on every single call — not merely by hiding the buttons: a
|
||||
* Livewire action is reachable by anyone who can post to /livewire/update,
|
||||
* and "the form was not on screen" has never stopped anybody.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class TwoFactorSetup extends Component
|
||||
{
|
||||
use ConfirmsPassword;
|
||||
|
||||
/** Two-factor is confirmed against the operator's own record, not a portal one. */
|
||||
protected function confirmationGuard(): string
|
||||
{
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
/** Shown once, right after setting two-factor up. */
|
||||
public ?array $recoveryCodes = null;
|
||||
|
||||
public string $twoFactorCode = '';
|
||||
|
||||
/**
|
||||
* Start two-factor setup: generate the secret, then show the QR code.
|
||||
*
|
||||
* Not confirmed yet — Fortify keeps `two_factor_confirmed_at` null until a
|
||||
* code from the app has been accepted, so someone who scans nothing and
|
||||
* closes the tab is not left locked out of their own account.
|
||||
*/
|
||||
public function enableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(EnableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
||||
}
|
||||
|
||||
/** Accept a code from the app, which is what actually turns it on. */
|
||||
public function confirmTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
|
||||
try {
|
||||
app(ConfirmTwoFactorAuthentication::class)(
|
||||
$operator,
|
||||
$this->twoFactorCode,
|
||||
);
|
||||
} catch (ValidationException $e) {
|
||||
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('two_factor_setup.code_wrong'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->twoFactorCode = '';
|
||||
// Shown once, here, because this is the only moment they exist in a
|
||||
// form anyone will read. They stay retrievable afterwards, but nobody
|
||||
// writes down what they were not shown.
|
||||
$this->recoveryCodes = json_decode(decrypt($operator->two_factor_recovery_codes), true);
|
||||
$this->dispatch('notify', message: __('two_factor_setup.on'));
|
||||
}
|
||||
|
||||
public function regenerateRecoveryCodes(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
app(GenerateNewRecoveryCodes::class)($operator);
|
||||
$this->recoveryCodes = json_decode(decrypt($operator->refresh()->two_factor_recovery_codes), true);
|
||||
}
|
||||
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(DisableTwoFactorAuthentication::class)(Auth::guard('operator')->user());
|
||||
$this->recoveryCodes = null;
|
||||
$this->dispatch('notify', message: __('two_factor_setup.off'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every two-factor action goes through this.
|
||||
*
|
||||
* Server-side, on each call, and not merely by hiding the buttons: a
|
||||
* Livewire action is reachable by anyone who can post to /livewire/update,
|
||||
* and "the form was not on screen" has never stopped anybody.
|
||||
*/
|
||||
private function requireConfirmedPassword(): void
|
||||
{
|
||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$operator = Auth::guard('operator')->user();
|
||||
|
||||
return view('livewire.admin.two-factor-setup', [
|
||||
// Never the secret itself — only whether it exists, and the SVG
|
||||
// Fortify renders from it. The secret in a Livewire property would
|
||||
// travel to the browser and back in the component snapshot.
|
||||
'twoFactorPending' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null,
|
||||
'twoFactorOn' => $operator->two_factor_confirmed_at !== null,
|
||||
'twoFactorQr' => $operator->two_factor_secret !== null && $operator->two_factor_confirmed_at === null
|
||||
? $operator->twoFactorQrCodeSvg()
|
||||
: null,
|
||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,11 @@ final class Navigation
|
|||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
['admin.secrets', 'lock', 'secrets', 'secrets.manage'],
|
||||
['admin.settings', 'settings', 'settings', null],
|
||||
// null capability, deliberately: the compulsory two-factor
|
||||
// switch (Admin\Settings::saveTwoFactorPolicy()) applies to
|
||||
// every operator, so enrolment has to be reachable by every
|
||||
// operator too, not only the ones who can manage the site.
|
||||
['admin.two-factor-setup', 'shield-check', 'two_factor_setup', null],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ return [
|
|||
'mail' => 'E-Mail',
|
||||
'secrets' => 'Zugangsdaten',
|
||||
'settings' => 'Einstellungen',
|
||||
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
|
||||
],
|
||||
|
||||
'version_hint' => 'Laufende Version und Commit — das, was zuletzt erfolgreich ausgerollt wurde.',
|
||||
|
|
|
|||
|
|
@ -76,37 +76,17 @@ return [
|
|||
'console_unlocked' => 'Einschränkung aufgehoben.',
|
||||
'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.',
|
||||
|
||||
// Two-factor policy — console.require_2fa.
|
||||
// Two-factor policy — console.require_2fa. Enrolment selbst ist eine
|
||||
// eigene Seite, admin.two-factor-setup (App\Livewire\Admin\TwoFactorSetup)
|
||||
// — siehe RequireOperatorTwoFactor dafür, warum nur die Einrichtung und
|
||||
// nicht diese ganze Seite von der erzwungenen Weiterleitung ausgenommen ist.
|
||||
'saved' => 'Gespeichert.',
|
||||
'two_factor_title' => 'Zwei-Faktor-Pflicht',
|
||||
'two_factor_body' => 'Gilt dann für jeden Mitarbeiter: Wer Zwei-Faktor nicht eingerichtet hat, kommt nur noch auf diese Seite.',
|
||||
'two_factor_body' => 'Gilt dann für jeden Mitarbeiter: Wer Zwei-Faktor nicht eingerichtet hat, kommt nur noch auf die Zwei-Faktor-Einrichtung.',
|
||||
'two_factor_require' => 'Für alle verbindlich machen',
|
||||
'two_factor_required' => 'Zwei-Faktor ist für die Konsole verbindlich. Bitte richten Sie sie hier ein.',
|
||||
'two_factor_self_first' => 'Richten Sie zuerst Ihre eigene Zwei-Faktor-Bestätigung ein — sonst sperren Sie sich mit diesem Schalter selbst aus.',
|
||||
|
||||
// My own two-factor enrolment — every operator, not only the Owner. This
|
||||
// is the page RequireOperatorTwoFactor exempts from its redirect, so it
|
||||
// has to actually work: the switch above is only fair if everyone it
|
||||
// applies to has somewhere to satisfy it.
|
||||
'twofa_title' => 'Meine Zwei-Faktor-Anmeldung',
|
||||
'twofa_sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt Ihr Konto auch dann, wenn das Passwort irgendwo abhandenkommt.',
|
||||
'twofa_state_on' => 'Aktiv',
|
||||
'twofa_state_off' => 'Nicht eingerichtet',
|
||||
'twofa_confirm_first' => 'Bitte zuerst Ihr Passwort bestätigen — eine angemeldete Sitzung allein genügt hier nicht.',
|
||||
'twofa_confirm_button' => 'Bestätigen',
|
||||
'twofa_enable' => 'Einrichten',
|
||||
'twofa_scan' => 'Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie danach die angezeigte Ziffernfolge ein.',
|
||||
'twofa_code' => 'Code aus der App',
|
||||
'twofa_activate' => 'Aktivieren',
|
||||
'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? Ihr Konto ist danach nur noch durch das Passwort geschützt.',
|
||||
'twofa_disable' => 'Entfernen',
|
||||
'twofa_new_codes' => 'Neue Wiederherstellungscodes',
|
||||
'twofa_codes_title' => 'Wiederherstellungscodes',
|
||||
'twofa_codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.',
|
||||
|
||||
'update_title' => 'Version & Aktualisierung',
|
||||
'update_version' => 'Läuft',
|
||||
'update_source' => 'Quelle',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'Meine Zwei-Faktor-Anmeldung',
|
||||
'sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt Ihr Konto auch dann, wenn das Passwort irgendwo abhandenkommt.',
|
||||
'state_on' => 'Aktiv',
|
||||
'state_off' => 'Nicht eingerichtet',
|
||||
'confirm_first' => 'Bitte zuerst Ihr Passwort bestätigen — eine angemeldete Sitzung allein genügt hier nicht.',
|
||||
'confirm_button' => 'Bestätigen',
|
||||
'enable' => 'Einrichten',
|
||||
'scan' => 'Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie danach die angezeigte Ziffernfolge ein.',
|
||||
'code' => 'Code aus der App',
|
||||
'activate' => 'Aktivieren',
|
||||
'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.',
|
||||
'disable' => 'Entfernen',
|
||||
'new_codes' => 'Neue Wiederherstellungscodes',
|
||||
'codes_title' => 'Wiederherstellungscodes',
|
||||
'codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.',
|
||||
];
|
||||
|
|
@ -24,6 +24,7 @@ return [
|
|||
'mail' => 'Email',
|
||||
'secrets' => 'Credentials',
|
||||
'settings' => 'Settings',
|
||||
'two_factor_setup' => 'Two-factor login',
|
||||
],
|
||||
|
||||
'version_hint' => 'Running version and commit — what last deployed successfully.',
|
||||
|
|
|
|||
|
|
@ -76,37 +76,17 @@ return [
|
|||
'console_unlocked' => 'Restriction lifted.',
|
||||
'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.',
|
||||
|
||||
// Two-factor policy — console.require_2fa.
|
||||
// Two-factor policy — console.require_2fa. Enrolment itself is its own
|
||||
// page, admin.two-factor-setup (App\Livewire\Admin\TwoFactorSetup) — see
|
||||
// RequireOperatorTwoFactor for why only enrolment, and not this whole
|
||||
// page, is exempt from the redirect this forces.
|
||||
'saved' => 'Saved.',
|
||||
'two_factor_title' => 'Two-factor requirement',
|
||||
'two_factor_body' => 'Applies to every member of staff: anyone who has not set two-factor up can reach this page and nothing else.',
|
||||
'two_factor_body' => 'Applies to every member of staff: anyone who has not set two-factor up can reach the two-factor setup page and nothing else.',
|
||||
'two_factor_require' => 'Make it compulsory for everyone',
|
||||
'two_factor_required' => 'Two-factor is compulsory for the console. Please set it up here.',
|
||||
'two_factor_self_first' => 'Set up your own two-factor confirmation first — otherwise this switch locks you out yourself.',
|
||||
|
||||
// My own two-factor enrolment — every operator, not only the Owner. This
|
||||
// is the page RequireOperatorTwoFactor exempts from its redirect, so it
|
||||
// has to actually work: the switch above is only fair if everyone it
|
||||
// applies to has somewhere to satisfy it.
|
||||
'twofa_title' => 'My two-factor login',
|
||||
'twofa_sub' => 'A code from an app, in addition to your password. Protects your account even if the password ends up somewhere it should not.',
|
||||
'twofa_state_on' => 'Active',
|
||||
'twofa_state_off' => 'Not set up',
|
||||
'twofa_confirm_first' => 'Please confirm your password first — a signed-in session alone is not enough here.',
|
||||
'twofa_confirm_button' => 'Confirm',
|
||||
'twofa_enable' => 'Set up',
|
||||
'twofa_scan' => 'Scan the code with your authenticator app, then enter the digits it shows.',
|
||||
'twofa_code' => 'Code from the app',
|
||||
'twofa_activate' => 'Activate',
|
||||
'twofa_code_wrong' => 'That code is not correct.',
|
||||
'twofa_on' => 'Two-factor login is active.',
|
||||
'twofa_off' => 'Two-factor login was removed.',
|
||||
'twofa_off_confirm' => 'Really remove two-factor login? Your account is then protected by the password alone.',
|
||||
'twofa_disable' => 'Remove',
|
||||
'twofa_new_codes' => 'New recovery codes',
|
||||
'twofa_codes_title' => 'Recovery codes',
|
||||
'twofa_codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each code works once.',
|
||||
|
||||
'update_title' => 'Version & update',
|
||||
'update_version' => 'Running',
|
||||
'update_source' => 'Source',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => 'My two-factor login',
|
||||
'sub' => 'A code from an app, in addition to your password. Protects your account even if the password ends up somewhere it should not.',
|
||||
'state_on' => 'Active',
|
||||
'state_off' => 'Not set up',
|
||||
'confirm_first' => 'Please confirm your password first — a signed-in session alone is not enough here.',
|
||||
'confirm_button' => 'Confirm',
|
||||
'enable' => 'Set up',
|
||||
'scan' => 'Scan the code with your authenticator app, then enter the digits it shows.',
|
||||
'code' => 'Code from the app',
|
||||
'activate' => 'Activate',
|
||||
'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.',
|
||||
'disable' => 'Remove',
|
||||
'new_codes' => 'New recovery codes',
|
||||
'codes_title' => 'Recovery codes',
|
||||
'codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each code works once.',
|
||||
];
|
||||
|
|
@ -298,91 +298,6 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
{{-- My own two-factor. Reachable by EVERY operator, not only the Owner —
|
||||
this is not gated behind $canManageSite, on purpose: the compulsory
|
||||
switch below applies to every member of staff, so every operator
|
||||
needs somewhere to satisfy it. This is also the page
|
||||
RequireOperatorTwoFactor exempts from the redirect it otherwise
|
||||
forces while the switch is on — without a working enrolment control
|
||||
here, that exemption would lead nowhere.
|
||||
|
||||
Every action is re-checked server-side against a recent password
|
||||
confirmation — hiding the buttons stops nobody who can post to
|
||||
/livewire/update. --}}
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:105ms]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.twofa_title') }}</h2>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $twoFactorOn ? __('admin_settings.twofa_state_on') : __('admin_settings.twofa_state_off') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('admin_settings.twofa_sub') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $passwordConfirmed)
|
||||
{{-- The gate. A signed-in session is not enough to change how the
|
||||
account is protected — the risk is an unlocked machine. --}}
|
||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface-2 p-4">
|
||||
<p class="text-sm text-body">{{ __('admin_settings.twofa_confirm_first') }}</p>
|
||||
<div class="mt-3 flex flex-wrap items-start gap-2">
|
||||
<div class="min-w-56 flex-1">
|
||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||
{{ __('admin_settings.twofa_confirm_button') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
@elseif ($twoFactorOn)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes" wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
|
||||
{{ __('admin_settings.twofa_new_codes') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="disableTwoFactor"
|
||||
wire:confirm="{{ __('admin_settings.twofa_off_confirm') }}"
|
||||
wire:loading.attr="disabled" wire:target="disableTwoFactor">
|
||||
{{ __('admin_settings.twofa_disable') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@elseif ($twoFactorPending)
|
||||
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
|
||||
<div class="rounded-lg border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
|
||||
<form wire:submit="confirmTwoFactor" class="space-y-3">
|
||||
<p class="text-sm text-muted">{{ __('admin_settings.twofa_scan') }}</p>
|
||||
<div class="max-w-48">
|
||||
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
|
||||
:label="__('admin_settings.twofa_code')" wire:model="twoFactorCode" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
|
||||
{{ __('admin_settings.twofa_activate') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||
{{ __('admin_settings.twofa_enable') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
|
||||
@if ($recoveryCodes)
|
||||
{{-- Shown once, on purpose: nobody writes down what they were never
|
||||
shown, and these are the way back in when the phone is gone. --}}
|
||||
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
|
||||
<p class="text-sm font-medium text-warning">{{ __('admin_settings.twofa_codes_title') }}</p>
|
||||
<p class="mt-1 text-xs text-warning">{{ __('admin_settings.twofa_codes_hint') }}</p>
|
||||
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-sm text-ink sm:grid-cols-4">
|
||||
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form wire:submit="saveAccount" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<h2 class="font-semibold text-ink">{{ __('admin_settings.account_title') }}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('two_factor_setup.sub') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Every action is re-checked server-side against a recent password
|
||||
confirmation — hiding the buttons stops nobody who can post to
|
||||
/livewire/update. --}}
|
||||
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $twoFactorOn ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $twoFactorOn ? __('two_factor_setup.state_on') : __('two_factor_setup.state_off') }}
|
||||
</span>
|
||||
|
||||
@if (! $passwordConfirmed)
|
||||
{{-- The gate. A signed-in session is not enough to change how the
|
||||
account is protected — the risk is an unlocked machine. --}}
|
||||
<form wire:submit="confirmPassword" class="rounded-lg border border-line bg-surface-2 p-4">
|
||||
<p class="text-sm text-body">{{ __('two_factor_setup.confirm_first') }}</p>
|
||||
<div class="mt-3 flex flex-wrap items-start gap-2">
|
||||
<div class="min-w-56 flex-1">
|
||||
<x-ui.input name="confirmablePassword" type="password" autocomplete="current-password"
|
||||
:label="__('admin_settings.password_current')" wire:model="confirmablePassword" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||
{{ __('two_factor_setup.confirm_button') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
@elseif ($twoFactorOn)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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">
|
||||
{{ __('two_factor_setup.disable') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@elseif ($twoFactorPending)
|
||||
<div class="grid gap-5 sm:grid-cols-[auto_1fr] sm:items-start">
|
||||
<div class="rounded-lg border border-line bg-white p-3">{!! $twoFactorQr !!}</div>
|
||||
<form wire:submit="confirmTwoFactor" class="space-y-3">
|
||||
<p class="text-sm text-muted">{{ __('two_factor_setup.scan') }}</p>
|
||||
<div class="max-w-48">
|
||||
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
|
||||
:label="__('two_factor_setup.code')" wire:model="twoFactorCode" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
|
||||
{{ __('two_factor_setup.activate') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||
{{ __('two_factor_setup.enable') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
|
||||
@if ($recoveryCodes)
|
||||
{{-- Shown once, on purpose: nobody writes down what they were never
|
||||
shown, and these are the way back in when the phone is gone. --}}
|
||||
<div class="rounded-lg border border-warning-border bg-warning-bg p-4">
|
||||
<p class="text-sm font-medium text-warning">{{ __('two_factor_setup.codes_title') }}</p>
|
||||
<p class="mt-1 text-xs text-warning">{{ __('two_factor_setup.codes_hint') }}</p>
|
||||
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 font-mono text-sm text-ink sm:grid-cols-4">
|
||||
@foreach ($recoveryCodes as $code)<li class="select-all">{{ $code }}</li>@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Controllers\ImpersonationController;
|
||||
use App\Livewire\Admin;
|
||||
use App\Services\Deployment\UpdateChannel;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ Route::get('/revenue', Admin\Revenue::class)->name('revenue');
|
|||
Route::get('/mail', Admin\Mail::class)->name('mail');
|
||||
Route::get('/secrets', Admin\Secrets::class)->name('secrets');
|
||||
Route::get('/settings', Admin\Settings::class)->name('settings');
|
||||
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
|
||||
// exempting the rest of admin.settings — see that middleware for why.
|
||||
Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup');
|
||||
|
||||
// POST so Laravel's CSRF middleware protects it, like every other state change.
|
||||
Route::post('/logout', function () {
|
||||
|
|
@ -74,7 +78,7 @@ Route::post('/logout', function () {
|
|||
* failed request here is expected (it IS the restart) and the poller keeps
|
||||
* trying rather than giving up.
|
||||
*/
|
||||
Route::get('/update/state', function (\App\Services\Deployment\UpdateChannel $channel) {
|
||||
Route::get('/update/state', function (UpdateChannel $channel) {
|
||||
$state = $channel->state();
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings;
|
||||
use App\Livewire\Admin\TwoFactorSetup;
|
||||
use Livewire\Livewire;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
|
|
@ -14,6 +14,13 @@ use PragmaRX\Google2FA\Google2FA;
|
|||
* unchanged, just pointed at the `operator` guard instead of the default
|
||||
* one via ConfirmsPassword::confirmationGuard().
|
||||
*
|
||||
* Its own component and route (App\Livewire\Admin\TwoFactorSetup,
|
||||
* admin.two-factor-setup) rather than a card on Admin\Settings — see
|
||||
* RequireOperatorTwoFactor for why: that component also carries staff
|
||||
* management, site visibility, network restrictions, account changes, and
|
||||
* the two-factor policy switch itself, none of which an unenrolled operator
|
||||
* should get for free.
|
||||
*
|
||||
* What matters here is the gate: every action is re-checked against a
|
||||
* recent password confirmation on the server, because a Livewire action is
|
||||
* reachable by anyone who can post to /livewire/update, and "the button was
|
||||
|
|
@ -26,7 +33,7 @@ it('refuses every two-factor action without a recent password confirmation', fun
|
|||
|
||||
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
|
||||
Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->call($action)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
|
@ -38,7 +45,7 @@ it('takes a password, then sets two-factor up and confirms it with a real code',
|
|||
$op = operator('Support');
|
||||
|
||||
$page = Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->assertHasNoErrors();
|
||||
|
|
@ -61,7 +68,7 @@ it('rejects a wrong code instead of switching it on', function () {
|
|||
$op = operator('Support');
|
||||
|
||||
Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->call('enableTwoFactor')
|
||||
|
|
@ -76,7 +83,7 @@ it('does not accept a wrong password, and says so without hinting', function ()
|
|||
$op = operator('Support');
|
||||
|
||||
Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'falsch')
|
||||
->call('confirmPassword')
|
||||
->assertHasErrors('confirmablePassword')
|
||||
|
|
@ -90,7 +97,7 @@ it('never puts the secret where the browser can see it', function () {
|
|||
$op = operator('Support');
|
||||
|
||||
$page = Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->call('enableTwoFactor');
|
||||
|
|
@ -107,30 +114,30 @@ it('actually renders the enrolment control on the page, not just in the componen
|
|||
$op = operator('Support');
|
||||
|
||||
// Nothing to enrol with yet — the password gate is what shows.
|
||||
$page = Livewire::actingAs($op, 'operator')->test(Settings::class);
|
||||
$page->assertSee(__('admin_settings.twofa_confirm_first'))
|
||||
->assertSee(__('admin_settings.twofa_state_off'));
|
||||
$page = Livewire::actingAs($op, 'operator')->test(TwoFactorSetup::class);
|
||||
$page->assertSee(__('two_factor_setup.confirm_first'))
|
||||
->assertSee(__('two_factor_setup.state_off'));
|
||||
|
||||
$page->set('confirmablePassword', 'password')->call('confirmPassword');
|
||||
$page->assertSee(__('admin_settings.twofa_enable'));
|
||||
$page->assertSee(__('two_factor_setup.enable'));
|
||||
|
||||
$page->call('enableTwoFactor');
|
||||
$page->assertSee(__('admin_settings.twofa_activate'))
|
||||
->assertSee(__('admin_settings.twofa_code'));
|
||||
$page->assertSee(__('two_factor_setup.activate'))
|
||||
->assertSee(__('two_factor_setup.code'));
|
||||
|
||||
$code = app(Google2FA::class)->getCurrentOtp(decrypt($op->refresh()->two_factor_secret));
|
||||
$page->set('twoFactorCode', $code)->call('confirmTwoFactor');
|
||||
|
||||
$page->assertSee(__('admin_settings.twofa_state_on'))
|
||||
->assertSee(__('admin_settings.twofa_codes_title'))
|
||||
->assertSee(__('admin_settings.twofa_disable'));
|
||||
$page->assertSee(__('two_factor_setup.state_on'))
|
||||
->assertSee(__('two_factor_setup.codes_title'))
|
||||
->assertSee(__('two_factor_setup.disable'));
|
||||
});
|
||||
|
||||
it('lets an operator regenerate recovery codes and disable two-factor once confirmed', function () {
|
||||
$op = operator('Support');
|
||||
|
||||
$page = Livewire::actingAs($op, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->call('enableTwoFactor');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\Settings;
|
||||
use App\Livewire\Admin\TwoFactorSetup;
|
||||
use App\Models\Operator;
|
||||
use App\Support\Settings as AppSettings;
|
||||
use Livewire\Livewire;
|
||||
|
|
@ -21,7 +22,24 @@ it('sends an operator without two-factor to the setup page once the switch is on
|
|||
|
||||
$this->actingAs($operator, 'operator')
|
||||
->get(route('admin.overview'))
|
||||
->assertRedirect(route('admin.settings'));
|
||||
->assertRedirect(route('admin.two-factor-setup'));
|
||||
});
|
||||
|
||||
it('no longer exempts admin.settings itself — only enrolment', function () {
|
||||
// Codex R15 P1b: the earlier shape exempted the whole of admin.settings,
|
||||
// on the theory that it was "the page where two-factor is set up". That
|
||||
// component also carries staff management, site visibility, network
|
||||
// restrictions, account changes, and the two-factor policy switch
|
||||
// itself — so an unenrolled operator got unrestricted access to all of
|
||||
// that, not just enrolment. An Owner here on purpose: staff management is
|
||||
// Owner-only, so this is the account that would have the most to reach.
|
||||
AppSettings::set('console.require_2fa', true);
|
||||
|
||||
$operator = Operator::factory()->role('Owner')->create();
|
||||
|
||||
$this->actingAs($operator, 'operator')
|
||||
->get(route('admin.settings'))
|
||||
->assertRedirect(route('admin.two-factor-setup'));
|
||||
});
|
||||
|
||||
it('lets an operator with confirmed two-factor through', function () {
|
||||
|
|
@ -60,17 +78,22 @@ it('lets an operator without two-factor genuinely enrol while the switch is on,
|
|||
// Bounced off an ordinary page…
|
||||
$this->actingAs($operator, 'operator')
|
||||
->get(route('admin.overview'))
|
||||
->assertRedirect(route('admin.settings'));
|
||||
->assertRedirect(route('admin.two-factor-setup'));
|
||||
|
||||
// …but the settings page itself is genuinely reachable, not another
|
||||
// bounce in disguise.
|
||||
// …and off admin.settings too — that page is no longer the exemption…
|
||||
$this->actingAs($operator, 'operator')
|
||||
->get(route('admin.settings'))
|
||||
->assertRedirect(route('admin.two-factor-setup'));
|
||||
|
||||
// …but the two-factor setup page itself is genuinely reachable, not
|
||||
// another bounce in disguise.
|
||||
$this->actingAs($operator, 'operator')
|
||||
->get(route('admin.two-factor-setup'))
|
||||
->assertOk();
|
||||
|
||||
// Enrol for real: password confirmation, then a real TOTP code.
|
||||
$page = Livewire::actingAs($operator, 'operator')
|
||||
->test(Settings::class)
|
||||
->test(TwoFactorSetup::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->assertHasNoErrors()
|
||||
|
|
@ -82,10 +105,15 @@ it('lets an operator without two-factor genuinely enrol while the switch is on,
|
|||
|
||||
expect($operator->refresh()->two_factor_confirmed_at)->not->toBeNull();
|
||||
|
||||
// And now the rest of the console actually opens.
|
||||
// And now the rest of the console actually opens — admin.settings
|
||||
// included, the very page this test proved was blocked above.
|
||||
$this->actingAs($operator->fresh(), 'operator')
|
||||
->get(route('admin.overview'))
|
||||
->assertOk();
|
||||
|
||||
$this->actingAs($operator->fresh(), 'operator')
|
||||
->get(route('admin.settings'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('allows it once your own two-factor is confirmed', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue