diff --git a/app/Livewire/Auth/RecoveryCodes.php b/app/Livewire/Modals/RecoveryCodes.php similarity index 51% rename from app/Livewire/Auth/RecoveryCodes.php rename to app/Livewire/Modals/RecoveryCodes.php index e0fefcc..a6c7edb 100644 --- a/app/Livewire/Auth/RecoveryCodes.php +++ b/app/Livewire/Modals/RecoveryCodes.php @@ -1,15 +1,28 @@ */ + 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()]); } } diff --git a/lang/de/auth.php b/lang/de/auth.php index 53f6ec7..da4ae13 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -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.', diff --git a/lang/en/auth.php b/lang/en/auth.php index e01e9f8..3567f8a 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -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.', diff --git a/resources/views/livewire/auth/recovery-codes.blade.php b/resources/views/livewire/auth/recovery-codes.blade.php deleted file mode 100644 index 09dab08..0000000 --- a/resources/views/livewire/auth/recovery-codes.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -
-
-

{{ __('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/modals/recovery-codes.blade.php b/resources/views/livewire/modals/recovery-codes.blade.php new file mode 100644 index 0000000..12c185c --- /dev/null +++ b/resources/views/livewire/modals/recovery-codes.blade.php @@ -0,0 +1,32 @@ +
+
+ + + +
+

{{ __('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 d765d1c..a277b13 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -107,7 +107,7 @@ @if ($twoFactorEnabled)
- {{ __('auth.recovery_manage') }} + {{ __('auth.recovery_manage') }} {{ __('common.disable') }}
@else diff --git a/routes/web.php b/routes/web.php index 5ecadfa..0692d6e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/RecoveryCodesModalTest.php b/tests/Feature/RecoveryCodesModalTest.php new file mode 100644 index 0000000..39bc95d --- /dev/null +++ b/tests/Feature/RecoveryCodesModalTest.php @@ -0,0 +1,43 @@ +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')); + } +} diff --git a/tests/Feature/RecoveryCodesViewTest.php b/tests/Feature/RecoveryCodesViewTest.php deleted file mode 100644 index 93dcaae..0000000 --- a/tests/Feature/RecoveryCodesViewTest.php +++ /dev/null @@ -1,55 +0,0 @@ -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()); - } -}