diff --git a/app/Livewire/Auth/RecoveryCodes.php b/app/Livewire/Auth/RecoveryCodes.php
new file mode 100644
index 0000000..e0fefcc
--- /dev/null
+++ b/app/Livewire/Auth/RecoveryCodes.php
@@ -0,0 +1,35 @@
+replaceRecoveryCodes();
+
+ AuditEvent::create([
+ 'user_id' => Auth::id(),
+ 'actor' => Auth::user()->name ?? 'system',
+ 'action' => 'two_factor.recovery_regenerate',
+ 'target' => Auth::user()->email,
+ 'ip' => request()->ip(),
+ ]);
+
+ $this->dispatch('notify', message: __('auth.recovery_regenerated'));
+ }
+
+ public function render()
+ {
+ return view('livewire.auth.recovery-codes', [
+ 'codes' => Auth::user()->recoveryCodes(),
+ ])->title(__('auth.title_recovery'));
+ }
+}
diff --git a/app/Livewire/Auth/TwoFactorSetup.php b/app/Livewire/Auth/TwoFactorSetup.php
index 273c1a2..8452e58 100644
--- a/app/Livewire/Auth/TwoFactorSetup.php
+++ b/app/Livewire/Auth/TwoFactorSetup.php
@@ -39,7 +39,10 @@ class TwoFactorSetup extends Component
'two_factor_confirmed_at' => now(),
])->save();
- return $this->redirect(route('dashboard'), navigate: true);
+ // Generate backup codes and show them once before entering the panel.
+ Auth::user()->replaceRecoveryCodes();
+
+ return $this->redirect(route('two-factor.recovery'), navigate: true);
}
public function qrCode(): string
diff --git a/resources/views/livewire/auth/recovery-codes.blade.php b/resources/views/livewire/auth/recovery-codes.blade.php
new file mode 100644
index 0000000..09dab08
--- /dev/null
+++ b/resources/views/livewire/auth/recovery-codes.blade.php
@@ -0,0 +1,28 @@
+
+
+
{{ __('auth.two_factor') }}
+
{{ __('auth.recovery_heading') }}
+
{{ __('auth.recovery_subtitle') }}
+
+
+
+
+
{{ __('auth.recovery_warning') }}
+
+
+
+ @foreach ($codes as $c)
+ {{ $c }}
+ @endforeach
+
+
+
+
+ {{ __('auth.recovery_download') }}
+
+
+ {{ __('auth.recovery_regenerate') }}
+
+ {{ __('auth.recovery_done') }}
+
+
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php
index 08b86e4..ba0f613 100644
--- a/resources/views/livewire/settings/index.blade.php
+++ b/resources/views/livewire/settings/index.blade.php
@@ -106,7 +106,10 @@
@if ($twoFactorEnabled)
- {{ __('common.disable') }}
+
+ {{ __('auth.recovery_manage') }}
+ {{ __('common.disable') }}
+
@else
{{ __('settings.twofa_setup') }}
diff --git a/routes/web.php b/routes/web.php
index 1d458ef..d2dae66 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -51,6 +51,19 @@ 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');
+ Route::get('/two-factor/recovery-codes/download', function () {
+ $codes = AuthFacade::user()->recoveryCodes();
+
+ return response()->streamDownload(
+ fn () => print (implode("\n", $codes)."\n"),
+ 'clusev-recovery-codes.txt',
+ ['Content-Type' => 'text/plain; charset=UTF-8'],
+ );
+ })->name('two-factor.recovery.download');
+
Route::post('/logout', function () {
AuthFacade::logout();
session()->invalidate();
diff --git a/tests/Feature/RecoveryCodesViewTest.php b/tests/Feature/RecoveryCodesViewTest.php
new file mode 100644
index 0000000..93dcaae
--- /dev/null
+++ b/tests/Feature/RecoveryCodesViewTest.php
@@ -0,0 +1,55 @@
+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());
+ }
+}