feat(2fa): recovery codes become a modal; drop the dedicated page + route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 20:50:11 +02:00
parent 82d5644767
commit e9a6a6c083
9 changed files with 97 additions and 98 deletions

View File

@ -1,15 +1,28 @@
<?php
namespace App\Livewire\Auth;
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use LivewireUI\Modal\ModalComponent;
#[Layout('layouts.auth')]
class RecoveryCodes extends Component
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
/** @return array<int, string> */
public function codes(): array
{
return Auth::user()->recoveryCodes();
}
/** Regenerate the current user's backup codes (invalidates the old set). */
public function regenerate(): void
{
@ -28,8 +41,6 @@ class RecoveryCodes extends Component
public function render()
{
return view('livewire.auth.recovery-codes', [
'codes' => Auth::user()->recoveryCodes(),
])->title(__('auth.title_recovery'));
return view('livewire.modals.recovery-codes', ['codes' => $this->codes()]);
}
}

View File

@ -74,7 +74,6 @@ return [
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
'title_recovery' => 'Backup-Codes — Clusev',
'recovery_heading' => 'Backup-Codes',
'recovery_subtitle' => 'Mit diesen Einmal-Codes meldest du dich an, wenn du keinen Authenticator hast.',
'recovery_warning' => 'Speichere diese Codes jetzt sicher ab — jeder Code funktioniert genau einmal. Lade sie herunter, solange sie angezeigt werden.',

View File

@ -74,7 +74,6 @@ return [
// ── 2FA backup (recovery) codes ──────────────────────────────────────
'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
'title_recovery' => 'Backup codes — Clusev',
'recovery_heading' => 'Backup codes',
'recovery_subtitle' => 'Use these one-time codes to sign in when you do not have your authenticator.',
'recovery_warning' => 'Save these codes somewhere safe now — each works exactly once. Download them while they are shown.',

View File

@ -1,28 +0,0 @@
<div class="space-y-5">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.two_factor') }}</p>
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.recovery_heading') }}</h1>
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
<div class="flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
@foreach ($codes as $c)
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="accent">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:confirm="{{ __('auth.recovery_regenerate_confirm') }}">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn href="{{ route('dashboard') }}" variant="primary" class="ml-auto">{{ __('auth.recovery_done') }}</x-btn>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('auth.recovery_heading') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
</div>
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
@foreach ($codes as $c)
<span class="font-mono text-sm tracking-wide text-ink">{{ $c }}</span>
@endforeach
</div>
<div class="mt-6 flex flex-wrap items-center gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn variant="primary" class="ml-auto" wire:click="$dispatch('closeModal')">{{ __('auth.recovery_done') }}</x-btn>
</div>
</div>

View File

@ -107,7 +107,7 @@
</div>
@if ($twoFactorEnabled)
<div class="flex items-center gap-2">
<x-btn variant="secondary" :href="route('two-factor.recovery')" wire:navigate>{{ __('auth.recovery_manage') }}</x-btn>
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
</div>
@else

View File

@ -53,9 +53,7 @@ Route::middleware('auth')->group(function () {
Route::get('/password', Auth\PasswordChange::class)->name('password.change');
Route::get('/two-factor-setup', Auth\TwoFactorSetup::class)->name('two-factor.setup');
// 2FA backup codes — shown right after enrolling (before the onboarding gate) and
// manageable later from Settings.
Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery');
// 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes).
Route::get('/two-factor/recovery-codes/download', function () {
$codes = AuthFacade::user()->recoveryCodes();
@ -74,7 +72,7 @@ Route::middleware('auth')->group(function () {
return redirect()->route('login');
})->name('logout');
// The panel — requires completed onboarding (password rotated + 2FA enrolled).
// The panel — requires completed onboarding (seeded password rotated).
Route::middleware(EnsureSecurityOnboarded::class)->group(function () {
Route::get('/', Dashboard::class)->name('dashboard');

View File

@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
use Tests\TestCase;
class RecoveryCodesModalTest extends TestCase
{
use RefreshDatabase;
public function test_modal_lists_the_current_codes(): void
{
$user = User::factory()->create();
$codes = $user->replaceRecoveryCodes();
Livewire::actingAs($user)->test(RecoveryCodes::class)
->assertSee($codes[0])
->assertSee($codes[7]);
}
public function test_regenerate_replaces_the_codes(): void
{
$user = User::factory()->create();
$old = $user->replaceRecoveryCodes();
Livewire::actingAs($user)->test(RecoveryCodes::class)
->call('regenerate')
->assertDontSee($old[0]);
$this->assertNotEquals($old, $user->fresh()->recoveryCodes());
}
public function test_old_recovery_route_is_gone(): void
{
$this->assertFalse(Route::has('two-factor.recovery'));
$this->assertTrue(Route::has('two-factor.recovery.download'));
}
}

View File

@ -1,55 +0,0 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\RecoveryCodes;
use App\Livewire\Auth\TwoFactorSetup;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
class RecoveryCodesViewTest extends TestCase
{
use RefreshDatabase;
public function test_confirming_2fa_generates_codes_and_redirects_to_recovery(): void
{
$user = User::factory()->create(['two_factor_secret' => null, 'two_factor_confirmed_at' => null]);
$this->actingAs($user);
$secret = (new Google2FA)->generateSecretKey();
$code = (new Google2FA)->getCurrentOtp($secret);
Livewire::test(TwoFactorSetup::class)
->set('secret', $secret)
->set('code', $code)
->call('confirm')
->assertRedirect(route('two-factor.recovery'));
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_regenerate_replaces_codes(): void
{
$user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]);
$old = $user->replaceRecoveryCodes();
$this->actingAs($user);
Livewire::test(RecoveryCodes::class)->call('regenerate');
$this->assertNotSame($old, $user->fresh()->recoveryCodes());
$this->assertCount(8, $user->fresh()->recoveryCodes());
}
public function test_download_returns_codes_as_text(): void
{
$user = User::factory()->create();
$user->replaceRecoveryCodes();
$this->actingAs($user);
$res = $this->get(route('two-factor.recovery.download'));
$res->assertOk();
$this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent());
}
}