Let a half-finished two-factor enrolment be cancelled, and rework the setup page
Found uncommitted in the working tree at the start of this session and carried in as its own commit so it can be reverted on its own. Not written here: a cancel action that clears an unconfirmed secret, the wording for every state of the page, and a rebuilt setup view. The suite is green with it in place, including the R18-R23 scan tests.feat/granted-plans
parent
d65ab71029
commit
184350494d
|
|
@ -70,6 +70,29 @@ class TwoFactorSetup extends Component
|
|||
app(EnableTwoFactorAuthentication::class)($operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step back out of a half-finished setup.
|
||||
*
|
||||
* Without this the only way out of the QR step was to navigate away, which
|
||||
* left an unconfirmed secret sitting on the account — invisible, because
|
||||
* nothing shows a pending enrolment anywhere else. Clearing it costs
|
||||
* nothing: it was never confirmed, so it never protected anything.
|
||||
*/
|
||||
public function cancelSetup(): void
|
||||
{
|
||||
$this->requireConfirmedPassword();
|
||||
|
||||
if (! $operator = $this->currentOperator()) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(DisableTwoFactorAuthentication::class)($operator);
|
||||
|
||||
$this->twoFactorCode = '';
|
||||
$this->resetErrorBag('twoFactorCode');
|
||||
$this->dispatch('notify', message: __('two_factor_setup.cancelled'));
|
||||
}
|
||||
|
||||
/** Accept a code from the app, which is what actually turns it on. */
|
||||
public function confirmTwoFactor(): void
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,4 +23,19 @@ return [
|
|||
'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.',
|
||||
'locked_title' => 'Gesperrt',
|
||||
'not_set_title' => 'Noch nicht eingerichtet',
|
||||
'not_set_body' => 'Ihr Konto ist derzeit nur durch das Passwort geschützt. Mit Zwei-Faktor braucht eine Anmeldung zusätzlich einen Code, der alle 30 Sekunden wechselt.',
|
||||
'need_app' => 'Sie brauchen dafür eine Authenticator-App auf dem Telefon — etwa 1Password, Bitwarden, Google Authenticator oder Aegis.',
|
||||
'active_title' => 'Zwei-Faktor ist aktiv',
|
||||
'active_body' => 'Jede Anmeldung an der Konsole verlangt zusätzlich einen Code aus Ihrer App.',
|
||||
'scan_title' => 'Code scannen und bestätigen',
|
||||
'step_two' => 'Schritt 2 von 2',
|
||||
'scan_step_open' => 'Öffnen Sie Ihre Authenticator-App und wählen Sie „Konto hinzufügen“.',
|
||||
'scan_step_scan' => 'Scannen Sie den Code links.',
|
||||
'scan_step_code' => 'Geben Sie die sechsstellige Ziffernfolge ein, die die App anzeigt.',
|
||||
'cancel' => 'Abbrechen',
|
||||
'cancelled' => 'Einrichtung abgebrochen. Es wurde nichts gespeichert.',
|
||||
'copy_codes' => 'Alle kopieren',
|
||||
'codes_copied' => 'Kopiert',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,4 +24,19 @@ return [
|
|||
'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.',
|
||||
'locked_title' => 'Locked',
|
||||
'not_set_title' => 'Not set up yet',
|
||||
'not_set_body' => 'Your account is currently protected by its password alone. With two-factor, signing in also needs a code that changes every 30 seconds.',
|
||||
'need_app' => 'You will need an authenticator app on your phone — 1Password, Bitwarden, Google Authenticator or Aegis, for instance.',
|
||||
'active_title' => 'Two-factor is active',
|
||||
'active_body' => 'Every console sign-in additionally asks for a code from your app.',
|
||||
'scan_title' => 'Scan the code and confirm',
|
||||
'step_two' => 'Step 2 of 2',
|
||||
'scan_step_open' => 'Open your authenticator app and choose "add account".',
|
||||
'scan_step_scan' => 'Scan the code on the left.',
|
||||
'scan_step_code' => 'Enter the six digits the app shows.',
|
||||
'cancel' => 'Cancel',
|
||||
'cancelled' => 'Setup cancelled. Nothing was saved.',
|
||||
'copy_codes' => 'Copy all',
|
||||
'codes_copied' => 'Copied',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,74 +1,177 @@
|
|||
<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>
|
||||
{{-- The state belongs beside the title, not floating inside the card: it is
|
||||
a property of the account, not of whichever step is on screen. --}}
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 animate-rise">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('two_factor_setup.title') }}</h1>
|
||||
<p class="mt-1 max-w-xl 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
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-pill border px-2.5 py-1 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>
|
||||
</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">{{ __('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" />
|
||||
{{-- Every action below is re-checked server-side against a recent password
|
||||
confirmation — hiding a button stops nobody who can post to
|
||||
/livewire/update. --}}
|
||||
|
||||
@if (! $passwordConfirmed)
|
||||
{{-- The gate. Being signed in is not enough to change how the account is
|
||||
protected: the realistic threat is an unlocked machine. --}}
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex gap-4">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted" aria-hidden="true">
|
||||
<x-ui.icon name="lock" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-ink">{{ __('two_factor_setup.locked_title') }}</h2>
|
||||
<p class="mt-1 max-w-lg text-sm text-muted">{{ __('two_factor_setup.confirm_first') }}</p>
|
||||
|
||||
<form wire:submit="confirmPassword" class="mt-4 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="primary" class="mt-7"
|
||||
wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||
{{ __('two_factor_setup.confirm_button') }}
|
||||
</x-ui.button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif ($twoFactorOn)
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex gap-4">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-success-bg text-success" aria-hidden="true">
|
||||
<x-ui.icon name="shield-check" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-ink">{{ __('two_factor_setup.active_title') }}</h2>
|
||||
<p class="mt-1 max-w-lg text-sm text-muted">{{ __('two_factor_setup.active_body') }}</p>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2">
|
||||
<x-ui.button variant="secondary" wire:click="regenerateRecoveryCodes"
|
||||
wire:loading.attr="disabled" wire:target="regenerateRecoveryCodes">
|
||||
<x-ui.icon name="rotate-ccw" class="size-4" />{{ __('two_factor_setup.new_codes') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button variant="danger"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-disable-two-factor' })">
|
||||
{{ __('two_factor_setup.disable') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
<x-ui.button type="submit" variant="secondary" class="mt-7" wire:loading.attr="disabled" wire:target="confirmPassword">
|
||||
{{ __('two_factor_setup.confirm_button') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@elseif ($twoFactorPending)
|
||||
{{-- The step that had no way out. The secret exists but is unconfirmed,
|
||||
so "Abbrechen" genuinely clears it rather than leaving a dangling
|
||||
enrolment that nothing else on the console would ever show. --}}
|
||||
<div class="rounded-lg border border-line bg-surface shadow-xs animate-rise">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-line px-6 py-4">
|
||||
<h2 class="font-semibold text-ink">{{ __('two_factor_setup.scan_title') }}</h2>
|
||||
<span class="font-mono text-xs text-muted">{{ __('two_factor_setup.step_two') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 p-6 sm:grid-cols-[auto_1fr] sm:items-start">
|
||||
<div class="mx-auto rounded-lg border border-line-strong bg-white p-3 sm:mx-0">
|
||||
{!! $twoFactorQr !!}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<ol class="space-y-2 text-sm text-body">
|
||||
<li class="flex gap-2">
|
||||
<span class="font-mono text-muted" aria-hidden="true">1.</span>
|
||||
<span>{{ __('two_factor_setup.scan_step_open') }}</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="font-mono text-muted" aria-hidden="true">2.</span>
|
||||
<span>{{ __('two_factor_setup.scan_step_scan') }}</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="font-mono text-muted" aria-hidden="true">3.</span>
|
||||
<span>{{ __('two_factor_setup.scan_step_code') }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<form wire:submit="confirmTwoFactor" class="mt-5">
|
||||
<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>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<x-ui.button type="submit" variant="primary"
|
||||
wire:loading.attr="disabled" wire:target="confirmTwoFactor">
|
||||
{{ __('two_factor_setup.activate') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button type="button" variant="ghost" wire:click="cancelSetup"
|
||||
wire:loading.attr="disabled" wire:target="cancelSetup">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />{{ __('two_factor_setup.cancel') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@else
|
||||
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex gap-4">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted" aria-hidden="true">
|
||||
<x-ui.icon name="shield" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-ink">{{ __('two_factor_setup.not_set_title') }}</h2>
|
||||
<p class="mt-1 max-w-lg text-sm text-muted">{{ __('two_factor_setup.not_set_body') }}</p>
|
||||
<p class="mt-4 text-xs text-muted">{{ __('two_factor_setup.need_app') }}</p>
|
||||
|
||||
<x-ui.button variant="primary" class="mt-5" wire:click="enableTwoFactor"
|
||||
wire:loading.attr="disabled" wire:target="enableTwoFactor">
|
||||
{{ __('two_factor_setup.enable') }}
|
||||
</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"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-disable-two-factor' })">
|
||||
{{ __('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
|
||||
</div>
|
||||
@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>
|
||||
@if ($recoveryCodes)
|
||||
{{-- Its own card, not a footnote under another one: these are the only
|
||||
way back in once the phone is gone, and they are shown exactly once. --}}
|
||||
<div class="rounded-lg border border-warning-border bg-warning-bg p-6 animate-rise"
|
||||
x-data="{
|
||||
copied: false,
|
||||
copy() {
|
||||
const text = [...$refs.codes.querySelectorAll('li')].map(n => n.textContent.trim()).join('\n');
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.copied = true;
|
||||
setTimeout(() => { this.copied = false; }, 2000);
|
||||
});
|
||||
},
|
||||
}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="flex items-center gap-2 font-semibold text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('two_factor_setup.codes_title') }}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-lg text-xs text-warning">{{ __('two_factor_setup.codes_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<x-ui.button type="button" variant="secondary" size="sm" x-on:click="copy()">
|
||||
<x-ui.icon name="copy" class="size-4" />
|
||||
<span x-text="copied ? @js(__('two_factor_setup.codes_copied')) : @js(__('two_factor_setup.copy_codes'))"></span>
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ul x-ref="codes" class="mt-4 grid grid-cols-2 gap-x-6 gap-y-1.5 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue