Give customers two-factor, and stop every button on the settings page reacting at once
Clicking Save made every button on the console's settings page appear to fire. Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one Livewire applies the loading state to ANY request on the component — so one save put all of them into their disabled state simultaneously. Each names its own action now. Two-factor for customers. Fortify's endpoints already existed; only the screen was missing. Setting up requires re-entering the password first, and every action re-checks that server-side rather than relying on the button not being on screen — a Livewire action is reachable by anyone who can post to /livewire/update. The confirmation marker is Laravel's own session key, so it and the framework's password.confirm middleware mean the same thing rather than drifting apart. The secret never enters a Livewire property. Component state travels to the browser and back in the snapshot; the QR image is derived from the secret, the secret is not in it — and there is a test that says so. The status page moves to the ROOT of its hostname: status.clupilot.com/status says the same word twice. That needed the domain-bound `/` registered BEFORE the landing page, because Laravel takes the first match and the landing route is host-agnostic — so the status host would have served the marketing site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0713-f6b9181
parent
6082164f8b
commit
f6b9181ed8
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* A password re-entry gate in front of the dangerous parts of a page.
|
||||
*
|
||||
* Used by anything where holding a signed-in session should not be enough on
|
||||
* its own: setting up two-factor authentication, and reading or changing stored
|
||||
* credentials. The threat is not a stranger — it is a colleague, or anyone, at
|
||||
* an unlocked machine.
|
||||
*
|
||||
* Stored in the SAME session key Laravel's own `password.confirm` middleware
|
||||
* uses, so a confirmation made here also satisfies any framework route behind
|
||||
* that middleware, and vice versa. Reinventing the marker would have produced
|
||||
* two independent notions of "recently confirmed", one of which would drift.
|
||||
*
|
||||
* Rate-limited, because a confirmation form is a password oracle: without a
|
||||
* limit it is an unauthenticated-feeling place to guess at a known account.
|
||||
*/
|
||||
trait ConfirmsPassword
|
||||
{
|
||||
public string $confirmablePassword = '';
|
||||
|
||||
/** How long one confirmation lasts. Laravel's own default. */
|
||||
protected function confirmationWindow(): int
|
||||
{
|
||||
return (int) config('auth.password_timeout', 10800);
|
||||
}
|
||||
|
||||
public function passwordRecentlyConfirmed(): bool
|
||||
{
|
||||
$at = (int) session('auth.password_confirmed_at', 0);
|
||||
|
||||
return $at > 0 && (time() - $at) < $this->confirmationWindow();
|
||||
}
|
||||
|
||||
public function confirmPassword(): void
|
||||
{
|
||||
$key = 'confirm-password:'.auth()->id().'|'.request()->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
||||
$this->addError('confirmablePassword', __('auth.throttle', [
|
||||
'seconds' => RateLimiter::availableIn($key),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Hash::check($this->confirmablePassword, auth()->user()->password)) {
|
||||
RateLimiter::hit($key, 60);
|
||||
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
|
||||
$this->reset('confirmablePassword');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::clear($key);
|
||||
$this->reset('confirmablePassword');
|
||||
|
||||
// Regenerated first: a confirmation raises what this session can do, and
|
||||
// a session id that was already known to someone else must not be the
|
||||
// one that gets the extra authority.
|
||||
session()->regenerate();
|
||||
session(['auth.password_confirmed_at' => time()]);
|
||||
}
|
||||
|
||||
/** Give up the confirmation early — leaving a page should not keep it open. */
|
||||
public function forgetPasswordConfirmation(): void
|
||||
{
|
||||
session()->forget('auth.password_confirmed_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* A value shown only in outline: the last four characters, and nothing else.
|
||||
*
|
||||
* Enough to tell two keys apart, useless to anyone reading over a shoulder
|
||||
* or scrolling back through a screen recording.
|
||||
*/
|
||||
protected function outline(?string $value, int $keep = 4): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::length($value) <= $keep
|
||||
? str_repeat('•', Str::length($value))
|
||||
: str_repeat('•', 8).Str::substr($value, -$keep);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,79 @@ use Livewire\WithFileUploads;
|
|||
class Settings extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\ChangesOwnPassword;
|
||||
use \App\Livewire\Concerns\ConfirmsPassword;
|
||||
|
||||
/** 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(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
|
||||
}
|
||||
|
||||
/** Accept a code from the app, which is what actually turns it on. */
|
||||
public function confirmTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
try {
|
||||
app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
|
||||
auth()->user(),
|
||||
$this->twoFactorCode,
|
||||
);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('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(auth()->user()->two_factor_recovery_codes), true);
|
||||
$this->dispatch('notify', message: __('settings.twofa_on'));
|
||||
}
|
||||
|
||||
public function regenerateRecoveryCodes(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
|
||||
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
|
||||
}
|
||||
|
||||
public function disableTwoFactor(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
|
||||
$this->recoveryCodes = null;
|
||||
$this->dispatch('notify', message: __('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);
|
||||
}
|
||||
|
||||
use ResolvesCustomer, WithFileUploads;
|
||||
|
||||
|
|
@ -152,7 +225,18 @@ class Settings extends Component
|
|||
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
|
||||
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
return view('livewire.settings', [
|
||||
// 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' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null,
|
||||
'twoFactorOn' => $user->two_factor_confirmed_at !== null,
|
||||
'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null
|
||||
? $user->twoFactorQrCodeSvg()
|
||||
: null,
|
||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
||||
'customer' => $c,
|
||||
'instance' => $instance,
|
||||
'branding' => $c?->brandingResolved(),
|
||||
|
|
|
|||
|
|
@ -53,4 +53,23 @@ return [
|
|||
'close_mismatch' => 'Bitte das Bestätigungswort korrekt eingeben.',
|
||||
'close_blocked_title' => 'Noch nicht möglich',
|
||||
'close_blocked' => 'Bitte kündigen Sie zuerst Ihr aktives Paket, bevor Sie das Konto schließen.',
|
||||
|
||||
'twofa_title' => 'Zwei-Faktor-Anmeldung',
|
||||
'twofa_sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt das 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? Das 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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -53,4 +53,23 @@ return [
|
|||
'close_mismatch' => 'Please type the confirmation word correctly.',
|
||||
'close_blocked_title' => 'Not possible yet',
|
||||
'close_blocked' => 'Please cancel your active package before closing the account.',
|
||||
|
||||
'twofa_title' => 'Two-factor sign-in',
|
||||
'twofa_sub' => 'A code from an app in addition to the password. Protects the account even if the password gets out.',
|
||||
'twofa_state_on' => 'Active',
|
||||
'twofa_state_off' => 'Not set up',
|
||||
'twofa_confirm_first' => '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 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.',
|
||||
'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 works once.'
|
||||
];
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
{{ __('admin_settings.update_running') }}
|
||||
</x-ui.button>
|
||||
@elseif ($update['available'])
|
||||
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled">
|
||||
<x-ui.button variant="primary" wire:click="requestUpdate" wire:loading.attr="disabled" wire:target="requestUpdate">
|
||||
{{ __('admin_settings.update_now') }}
|
||||
</x-ui.button>
|
||||
@else
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled">
|
||||
<x-ui.button :variant="$sitePublic ? 'secondary' : 'primary'" wire:click="toggleSiteVisibility" wire:loading.attr="disabled" wire:target="toggleSiteVisibility">
|
||||
<x-ui.icon :name="$sitePublic ? 'lock' : 'unlock'" class="size-4" />
|
||||
{{ $sitePublic ? __('admin_settings.site_hide') : __('admin_settings.site_show') }}
|
||||
</x-ui.button>
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<x-ui.button :variant="$consoleRestricted ? 'secondary' : 'primary'"
|
||||
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled">
|
||||
wire:click="toggleConsoleRestriction" wire:loading.attr="disabled" wire:target="toggleConsoleRestriction">
|
||||
<x-ui.icon :name="$consoleRestricted ? 'unlock' : 'lock'" class="size-4" />
|
||||
{{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }}
|
||||
</x-ui.button>
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
<div class="flex-1">
|
||||
<x-ui.input name="consoleIp" wire:model="consoleIp" placeholder="203.0.113.7" />
|
||||
</div>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
<x-ui.button type="submit" wire:loading.attr="disabled" wire:target="addConsoleIp">{{ __('admin_settings.console_add') }}</x-ui.button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-faint">{{ __('admin_settings.console_ip_hint') }}</p>
|
||||
</form>
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled">{{ __('admin_settings.password_save') }}</x-ui.button>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="updateOwnPassword">{{ __('admin_settings.password_save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,84 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
{{-- Two-factor. 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-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:150ms]">
|
||||
<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">{{ __('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 ? __('settings.twofa_state_on') : __('settings.twofa_state_off') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 max-w-xl text-sm text-muted">{{ __('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">{{ __('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">
|
||||
{{ __('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">
|
||||
{{ __('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">
|
||||
{{ __('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">{{ __('settings.twofa_scan') }}</p>
|
||||
<div class="max-w-48">
|
||||
<x-ui.input name="twoFactorCode" inputmode="numeric" autocomplete="one-time-code"
|
||||
:label="__('settings.twofa_code')" wire:model="twoFactorCode" />
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="confirmTwoFactor">
|
||||
{{ __('settings.twofa_activate') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<x-ui.button variant="primary" wire:click="enableTwoFactor" wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||
{{ __('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">{{ __('settings.twofa_codes_title') }}</p>
|
||||
<p class="mt-1 text-xs text-warning">{{ __('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="saveBranding" class="space-y-4 rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('settings.branding_title') }}</h2>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,30 @@ if (AdminArea::isExclusive()) {
|
|||
->name('admin.legacy');
|
||||
}
|
||||
|
||||
// The service status page. Its own address: it used to sit under /legal beside
|
||||
// the imprint and the terms, and nothing about the current health of the
|
||||
// platform is a legal document.
|
||||
//
|
||||
// Bound to its own hostname when one is configured, and every other host
|
||||
// redirects there rather than 404ing — a status page is the one address people
|
||||
// keep in a bookmark and reach for when something is already wrong.
|
||||
$statusHost = (string) config('admin_access.status_host');
|
||||
|
||||
if ($statusHost !== '') {
|
||||
// At the ROOT of its own hostname. "status.clupilot.com/status" says the
|
||||
// same word twice and reads like a mistake, because it is one.
|
||||
//
|
||||
// Registered BEFORE the landing page below: `/` exists on both, Laravel
|
||||
// takes the first match, and a host-agnostic route registered earlier wins
|
||||
// over a domain-bound one registered later.
|
||||
Route::domain($statusHost)->get('/', StatusController::class)->name('status');
|
||||
// The old path on that host keeps working rather than 404ing.
|
||||
Route::domain($statusHost)->get('/status', fn () => redirect()->to('/', 301));
|
||||
Route::get('/status', fn () => redirect()->away('https://'.$statusHost, 301))->name('status.elsewhere');
|
||||
} else {
|
||||
Route::get('/status', StatusController::class)->name('status');
|
||||
}
|
||||
|
||||
Route::get('/', LandingController::class)->name('home');
|
||||
|
||||
// Generated, not a static file: while the site is hidden this has to say so,
|
||||
|
|
@ -90,22 +114,6 @@ Route::get('/robots.txt', function () {
|
|||
// Stripe webhook — paid order → customer provisioning run (CSRF-exempt, signed).
|
||||
Route::post('/webhooks/stripe', StripeWebhookController::class)->name('webhooks.stripe');
|
||||
|
||||
// The service status page. Its own address: it used to sit under /legal beside
|
||||
// the imprint and the terms, and nothing about the current health of the
|
||||
// platform is a legal document.
|
||||
//
|
||||
// Bound to its own hostname when one is configured, and every other host
|
||||
// redirects there rather than 404ing — a status page is the one address people
|
||||
// keep in a bookmark and reach for when something is already wrong.
|
||||
$statusHost = (string) config('admin_access.status_host');
|
||||
|
||||
if ($statusHost !== '') {
|
||||
Route::domain($statusHost)->get('/status', StatusController::class)->name('status');
|
||||
Route::get('/status', fn () => redirect()->away('https://'.$statusHost.'/status', 301))->name('status.elsewhere');
|
||||
} else {
|
||||
Route::get('/status', StatusController::class)->name('status');
|
||||
}
|
||||
|
||||
// Public legal pages (placeholders — replace with real content before launch).
|
||||
Route::prefix('legal')->name('legal.')->group(function () {
|
||||
Route::get('/impressum', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Settings as PortalSettings;
|
||||
use App\Models\Customer;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Customers setting up two-factor themselves.
|
||||
*
|
||||
* The endpoints existed; only the screen was missing. 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 not on screen" has never stopped anyone.
|
||||
*/
|
||||
|
||||
function portalUser(): App\Models\User
|
||||
{
|
||||
$customer = Customer::factory()->create(['status' => 'active']);
|
||||
$user = $customer->ensureUser();
|
||||
$user->forceFill(['password' => bcrypt('password')])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('refuses every two-factor action without a recent password confirmation', function () {
|
||||
$user = portalUser();
|
||||
|
||||
foreach (['enableTwoFactor', 'confirmTwoFactor', 'disableTwoFactor', 'regenerateRecoveryCodes'] as $action) {
|
||||
Livewire::actingAs($user)
|
||||
->test(PortalSettings::class)
|
||||
->call($action)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
expect($user->refresh()->two_factor_secret)->toBeNull();
|
||||
});
|
||||
|
||||
it('takes a password, then sets two-factor up and confirms it with a real code', function () {
|
||||
$user = portalUser();
|
||||
|
||||
$page = Livewire::actingAs($user)
|
||||
->test(PortalSettings::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$page->call('enableTwoFactor');
|
||||
expect($user->refresh()->two_factor_secret)->not->toBeNull()
|
||||
// Not on until a code has been accepted: someone who scans nothing and
|
||||
// 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));
|
||||
|
||||
$page->set('twoFactorCode', $code)->call('confirmTwoFactor')->assertHasNoErrors();
|
||||
|
||||
expect($user->refresh()->two_factor_confirmed_at)->not->toBeNull()
|
||||
// Shown once, here, because this is the only moment anyone reads them.
|
||||
->and($page->get('recoveryCodes'))->toBeArray()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects a wrong code instead of switching it on', function () {
|
||||
$user = portalUser();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(PortalSettings::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->call('enableTwoFactor')
|
||||
->set('twoFactorCode', '000000')
|
||||
->call('confirmTwoFactor')
|
||||
->assertHasErrors('twoFactorCode');
|
||||
|
||||
expect($user->refresh()->two_factor_confirmed_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not accept a wrong password, and says so without hinting', function () {
|
||||
$user = portalUser();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(PortalSettings::class)
|
||||
->set('confirmablePassword', 'falsch')
|
||||
->call('confirmPassword')
|
||||
->assertHasErrors('confirmablePassword')
|
||||
->call('enableTwoFactor')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('never puts the secret where the browser can see it', function () {
|
||||
// A Livewire property travels to the browser and back in the component
|
||||
// snapshot. The QR image is derived from the secret; the secret is not.
|
||||
$user = portalUser();
|
||||
|
||||
$page = Livewire::actingAs($user)
|
||||
->test(PortalSettings::class)
|
||||
->set('confirmablePassword', 'password')
|
||||
->call('confirmPassword')
|
||||
->call('enableTwoFactor');
|
||||
|
||||
$secret = decrypt($user->refresh()->two_factor_secret);
|
||||
|
||||
expect(json_encode($page->snapshot))->not->toContain($secret);
|
||||
});
|
||||
|
|
@ -49,3 +49,22 @@ it('does not call the estate protected when an instance has no backup at all', f
|
|||
->assertOk()
|
||||
->assertSee(__('status.detail.backups', ['stale' => 1, 'total' => 2]));
|
||||
});
|
||||
|
||||
it('serves the status page at the root of its own hostname, and the landing page everywhere else', function () {
|
||||
// "status.clupilot.com/status" says the same word twice. And the generic
|
||||
// `/` route is registered host-agnostically, so a domain-bound `/` has to
|
||||
// come FIRST or Laravel matches the landing page on the status host.
|
||||
config()->set('admin_access.status_host', 'status.example.test');
|
||||
|
||||
$router = new Illuminate\Routing\Router(app('events'), app());
|
||||
Illuminate\Support\Facades\Route::swap($router);
|
||||
require base_path('routes/web.php');
|
||||
$router->getRoutes()->refreshNameLookups();
|
||||
|
||||
$matches = fn (string $host, string $path) => $router->getRoutes()
|
||||
->match(Illuminate\Http\Request::create("http://{$host}{$path}", 'GET'))
|
||||
->getActionName();
|
||||
|
||||
expect($matches('status.example.test', '/'))->toContain('StatusController')
|
||||
->and($matches('www.example.test', '/'))->toContain('LandingController');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue