diff --git a/docs/superpowers/plans/2026-06-14-account-recovery.md b/docs/superpowers/plans/2026-06-14-account-recovery.md new file mode 100644 index 0000000..04fdfcf --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-account-recovery.md @@ -0,0 +1,1098 @@ +# Account Recovery Implementation Plan (Phase 1) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add account recovery to Clusev's custom auth — 2FA backup codes, a forgot-password flow, and a CLI lockout fallback. + +**Architecture:** Recovery codes live encrypted on the `users` row (`encrypted:array`), generated on 2FA confirm, usable at the existing TOTP challenge and as the forgot-password proof. Forgot-password is 2FA-code-gated (email link only when SMTP is set). A `clusev:reset-admin` command is the shell last-resort. + +**Tech Stack:** Laravel 13, Livewire 3, `pragmarx/google2fa-qrcode` (existing TOTP), Pest/PHPUnit. All tooling in the `app` container (R8). UI copy bilingual via `lang/{de,en}/auth.php` (R16). Password policy reused: `Password::min(12)->mixedCase()->numbers()`. + +**Conventions:** +- Tests: `docker compose exec -T app php artisan test --filter=` +- Migrate (test DB auto): tests use `RefreshDatabase`; dev: `docker compose exec -T app php artisan migrate` +- Pint: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty` +- Commits: Conventional Commits + the Co-Authored-By trailer. + +--- + +### Task 1: Recovery-code storage + User methods + +**Files:** +- Create: `database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php` +- Modify: `app/Models/User.php` +- Test: `tests/Feature/UserRecoveryCodesTest.php` + +- [ ] **Step 1: Migration** + +```php +text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('two_factor_recovery_codes'); + }); + } +}; +``` + +- [ ] **Step 2: Write the failing test** + +```php +create(); + $codes = $user->replaceRecoveryCodes(); + + $this->assertCount(8, $codes); + $this->assertCount(8, array_unique($codes)); + $this->assertSame($codes, $user->fresh()->recoveryCodes()); + $this->assertTrue($user->fresh()->hasRecoveryCodes()); + } + + public function test_use_recovery_code_consumes_once(): void + { + $user = User::factory()->create(); + $codes = $user->replaceRecoveryCodes(); + $one = $codes[0]; + + $this->assertTrue($user->useRecoveryCode(' '.$one.' ')); // trims + $this->assertFalse($user->fresh()->useRecoveryCode($one)); // already consumed + $this->assertFalse($user->fresh()->useRecoveryCode('not-a-code')); + $this->assertCount(7, $user->fresh()->recoveryCodes()); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest` +Expected: FAIL (`replaceRecoveryCodes` undefined). + +- [ ] **Step 4: Add cast + methods to `app/Models/User.php`** + +Add `'two_factor_recovery_codes' => 'encrypted:array'` to `casts()`. Add `two_factor_recovery_codes` to the `#[Hidden(...)]` attribute list. Add `use Illuminate\Support\Str;` and these methods: + +```php + /** @return array The 8 freshly generated one-time codes (also persisted). */ + public function replaceRecoveryCodes(): array + { + $codes = array_map( + fn () => Str::random(10).'-'.Str::random(10), + range(1, 8), + ); + + $this->forceFill(['two_factor_recovery_codes' => $codes])->save(); + + return $codes; + } + + /** @return array */ + public function recoveryCodes(): array + { + return $this->two_factor_recovery_codes ?? []; + } + + public function hasRecoveryCodes(): bool + { + return count($this->recoveryCodes()) > 0; + } + + /** Consume one recovery code (one-time). Returns true if it was valid + removed. */ + public function useRecoveryCode(string $code): bool + { + $code = trim($code); + $codes = $this->recoveryCodes(); + + if (! in_array($code, $codes, true)) { + return false; + } + + $this->forceFill([ + 'two_factor_recovery_codes' => array_values(array_filter($codes, fn ($c) => $c !== $code)), + ])->save(); + + return true; + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `docker compose exec -T app php artisan test --filter=UserRecoveryCodesTest` +Expected: PASS (2 tests). + +- [ ] **Step 6: Migrate dev DB + commit** + +```bash +docker compose exec -T app php artisan migrate +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add database/migrations/2026_06_14_000001_add_two_factor_recovery_codes_to_users_table.php app/Models/User.php tests/Feature/UserRecoveryCodesTest.php +git commit -m "feat(auth): store 2FA recovery codes (encrypted) on users" +``` + +--- + +### Task 2: Challenge accepts a backup code + +**Files:** +- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` +- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` +- Modify: `lang/de/auth.php`, `lang/en/auth.php` +- Test: `tests/Feature/TwoFactorChallengeRecoveryTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +create([ + 'two_factor_secret' => (new \PragmaRX\Google2FAQRCode\Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + } + + public function test_recovery_code_logs_in_and_is_consumed(): void + { + $user = $this->enrolledUser(); + $code = $user->replaceRecoveryCodes()[0]; + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorChallenge::class) + ->set('code', $code) + ->call('verify'); + + $this->assertAuthenticatedAs($user); + $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed + } + + public function test_invalid_code_fails(): void + { + $user = $this->enrolledUser(); + $user->replaceRecoveryCodes(); + session()->put('2fa.user', $user->id); + + Livewire::test(TwoFactorChallenge::class) + ->set('code', '000000') + ->call('verify') + ->assertHasErrors('code'); + + $this->assertGuest(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest` +Expected: FAIL (recovery code rejected → not authenticated). + +- [ ] **Step 3: Accept a recovery code in `verify()`** + +In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the validity check block: + +```php + $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)); + + if (! $valid) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); + } +``` + +with (TOTP first, then a one-time recovery code): + +```php + $valid = (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)) + || $user->useRecoveryCode($this->code); + + if (! $valid) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker compose exec -T app php artisan test --filter=TwoFactorChallengeRecoveryTest` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add the challenge hint (view + lang)** + +`lang/de/auth.php` add: `'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',` +`lang/en/auth.php` add: `'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',` + +In `resources/views/livewire/auth/two-factor-challenge.blade.php`, after the code input's `@error(...)` block, add: +```blade +

{{ __('auth.challenge_recovery_hint') }}

+``` + +- [ ] **Step 6: Pint + commit** + +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/TwoFactorChallengeRecoveryTest.php +git commit -m "feat(auth): accept a 2FA backup code at the login challenge" +``` + +--- + +### Task 3: Recovery-codes view (show once + download + regenerate) + setup hook + +**Files:** +- Create: `app/Livewire/Auth/RecoveryCodes.php` +- Create: `resources/views/livewire/auth/recovery-codes.blade.php` +- Modify: `app/Livewire/Auth/TwoFactorSetup.php`, `routes/web.php`, `lang/de/auth.php`, `lang/en/auth.php` +- Modify: `resources/views/livewire/settings/index.blade.php` (add a link — see step 6) +- Test: `tests/Feature/RecoveryCodesViewTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +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(); + $res->assertHeader('content-type', 'text/plain; charset=UTF-8'); + $this->assertStringContainsString($user->recoveryCodes()[0], $res->streamedContent()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest` +Expected: FAIL (route/component missing). + +- [ ] **Step 3: Create `app/Livewire/Auth/RecoveryCodes.php`** + +```php +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')); + } +} +``` + +- [ ] **Step 4: View `resources/views/livewire/auth/recovery-codes.blade.php`** + +```blade +
+
+

{{ __('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') }} +
+
+``` + +- [ ] **Step 5: Hook setup + routes + download** + +In `app/Livewire/Auth/TwoFactorSetup.php::confirm()`, after the `forceFill([... 'two_factor_confirmed_at' => now()])->save();` add `Auth::user()->replaceRecoveryCodes();` and change the redirect to `return $this->redirect(route('two-factor.recovery'), navigate: true);`. + +In `routes/web.php`, inside the `auth` group (before the `EnsureSecurityOnboarded` group, so a just-enrolled user can see codes before the dashboard gate — place beside `two-factor.setup`): +```php + Route::get('/two-factor/recovery-codes', Auth\RecoveryCodes::class)->name('two-factor.recovery'); + Route::get('/two-factor/recovery-codes/download', function () { + $codes = \Illuminate\Support\Facades\Auth::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'); +``` +(`use App\Livewire\Auth;` already imported.) + +- [ ] **Step 6: Settings link + lang** + +In `resources/views/livewire/settings/index.blade.php`, in the security/2FA section add (near the disable-2FA control): +```blade +{{ __('auth.recovery_manage') }} +``` + +`lang/de/auth.php` add: +```php + '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 und sie werden nicht erneut im Klartext gezeigt, wenn du sie nicht herunterlädst.', + 'recovery_download' => 'Als Datei herunterladen', + 'recovery_regenerate' => 'Neu erzeugen', + 'recovery_regenerate_confirm' => 'Neue Codes erzeugen? Die alten werden ungültig.', + 'recovery_regenerated' => 'Neue Backup-Codes erzeugt.', + 'recovery_done' => 'Gespeichert — weiter', + 'recovery_manage' => 'Backup-Codes verwalten', +``` +`lang/en/auth.php` add: +```php + '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 and they are not shown again in plain text unless you download them.', + 'recovery_download' => 'Download as file', + 'recovery_regenerate' => 'Regenerate', + 'recovery_regenerate_confirm' => 'Generate new codes? The old ones stop working.', + 'recovery_regenerated' => 'New backup codes generated.', + 'recovery_done' => 'Saved — continue', + 'recovery_manage' => 'Manage backup codes', +``` + +- [ ] **Step 7: Run tests, Pint, build, commit** + +Run: `docker compose exec -T app php artisan test --filter=RecoveryCodesViewTest` → PASS (3). +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +docker compose exec -T -u "1002:1002" app npm run build +git add app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php app/Livewire/Auth/TwoFactorSetup.php routes/web.php resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/RecoveryCodesViewTest.php +git commit -m "feat(auth): show + download + regenerate 2FA backup codes" +``` + +--- + +### Task 4: Forgot-password (2FA-code proof) + +**Files:** +- Create: `app/Livewire/Auth/ForgotPassword.php`, `resources/views/livewire/auth/forgot-password.blade.php` +- Modify: `routes/web.php`, `resources/views/livewire/auth/login.blade.php`, `lang/de/auth.php`, `lang/en/auth.php` +- Test: `tests/Feature/ForgotPasswordTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +create([ + 'email' => 'admin@clusev.local', + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + } + + public function test_totp_resets_password(): void + { + $user = $this->user(); + $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email)->set('code', $otp) + ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') + ->call('reset')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); + } + + public function test_backup_code_resets_and_is_consumed(): void + { + $user = $this->user(); + $code = $user->replaceRecoveryCodes()[0]; + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email)->set('code', $code) + ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') + ->call('reset')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); + $this->assertFalse($user->fresh()->useRecoveryCode($code)); + } + + public function test_wrong_code_does_not_reset(): void + { + $user = $this->user(); + $old = $user->password; + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email)->set('code', '000000') + ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') + ->call('reset')->assertHasErrors('code'); + + $this->assertSame($old, $user->fresh()->password); + } + + public function test_weak_password_rejected(): void + { + $user = $this->user(); + $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email)->set('code', $otp) + ->set('password', 'short')->set('password_confirmation', 'short') + ->call('reset')->assertHasErrors('password'); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=ForgotPasswordTest` +Expected: FAIL (component missing). + +- [ ] **Step 3: Create `app/Livewire/Auth/ForgotPassword.php`** + +```php +validate([ + 'email' => ['required', 'email'], + 'code' => ['required', 'string'], + 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], + ]); + + $key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip()); + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + $user = User::where('email', $this->email)->first(); + $clean = preg_replace('/\s+/', '', $this->code); + + $ok = $user + && $user->hasTwoFactorEnabled() + && ((new Google2FA)->verifyKey($user->two_factor_secret, $clean) || $user->useRecoveryCode($this->code)); + + if (! $ok) { + RateLimiter::hit($key, 60); + // Generic message — never reveal whether the email exists. + throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); + } + + RateLimiter::clear($key); + $user->forceFill(['password' => Hash::make($this->password), 'must_change_password' => false])->save(); + + AuditEvent::create([ + 'user_id' => $user->id, + 'actor' => $user->name ?? 'system', + 'action' => 'password.reset', + 'target' => $user->email, + 'ip' => request()->ip(), + ]); + + session()->flash('status', __('auth.reset_done')); + + return $this->redirect(route('login'), navigate: true); + } + + public function render() + { + return view('livewire.auth.forgot-password')->title(__('auth.title_forgot')); + } +} +``` + +- [ ] **Step 4: View `resources/views/livewire/auth/forgot-password.blade.php`** + +Mirror the login view's field classes ($label/$field/$err can be redefined inline as in login). Minimal structure: +```blade +@php + $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp +
+
+

{{ __('auth.security') }}

+

{{ __('auth.forgot_heading') }}

+

{{ __('auth.forgot_subtitle') }}

+
+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('code')

{{ $message }}

@enderror +

{{ __('auth.forgot_code_hint') }}

+
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+ {{ __('auth.forgot_submit') }} +
+ + @if ($this->mailEnabled()) +

{{ __('auth.forgot_email_alt') }}

+ @endif + + {{ __('auth.back_to_login') }} +
+``` + +- [ ] **Step 5: Route + login link** + +`routes/web.php` guest group, add: +```php + Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); +``` +`resources/views/livewire/auth/login.blade.php` — after the password field's `@error` block (before the remember-me label), add: +```blade +{{ __('auth.forgot_link') }} +``` + +- [ ] **Step 6: Lang keys** + +`lang/de/auth.php` add: `'title_forgot' => 'Passwort vergessen — Clusev'`, `'forgot_link' => 'Passwort vergessen?'`, `'forgot_heading' => 'Passwort zurücksetzen'`, `'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.'`, `'forgot_code' => '2FA-Code oder Backup-Code'`, `'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.'`, `'forgot_submit' => 'Passwort zurücksetzen'`, `'forgot_email_alt' => 'E-Mail-Reset ist konfiguriert — Link auf Anfrage verfügbar.'`, `'reset_invalid' => 'E-Mail oder Code ungültig.'`, `'reset_done' => 'Passwort geändert. Bitte neu anmelden.'` +`lang/en/auth.php` add: `'title_forgot' => 'Forgot password — Clusev'`, `'forgot_link' => 'Forgot password?'`, `'forgot_heading' => 'Reset password'`, `'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.'`, `'forgot_code' => '2FA code or backup code'`, `'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.'`, `'forgot_submit' => 'Reset password'`, `'forgot_email_alt' => 'Email reset is configured — link available on request.'`, `'reset_invalid' => 'Invalid email or code.'`, `'reset_done' => 'Password changed. Please sign in again.'` + +- [ ] **Step 7: Run tests, Pint, build, commit** + +Run: `docker compose exec -T app php artisan test --filter=ForgotPasswordTest` → PASS (4). +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +docker compose exec -T -u "1002:1002" app npm run build +git add app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php resources/views/livewire/auth/login.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/ForgotPasswordTest.php +git commit -m "feat(auth): forgot-password reset via 2FA or backup code" +``` + +--- + +### Task 4b: Email reset link (gated on SMTP) + +Builds the secondary email-link path so it activates the moment a real mailer is configured. With `MAIL_MAILER=log` it stays hidden in the UI; tested with `Mail::fake` + a `mail.default` override. `User` already uses Laravel's `CanResetPassword` trait, and `password_reset_tokens` exists. + +**Files:** +- Create: `app/Livewire/Auth/ResetPassword.php`, `resources/views/livewire/auth/reset-password.blade.php` +- Modify: `app/Livewire/Auth/ForgotPassword.php` (add `sendResetLink()`), `resources/views/livewire/auth/forgot-password.blade.php` (conditional button), `routes/web.php`, `lang/de/auth.php`, `lang/en/auth.php` +- Test: `tests/Feature/EmailResetTest.php` + +- [ ] **Step 1: Write the failing test** + +```php + 'log']); + Livewire::test(ForgotPassword::class)->assertReturned(fn () => true); // smoke + $this->assertFalse((new ForgotPassword)->mailEnabled()); + } + + public function test_send_link_dispatches_notification_when_mail_configured(): void + { + config(['mail.default' => 'smtp']); + Notification::fake(); + $user = User::factory()->create(['email' => 'admin@clusev.local']); + + Livewire::test(ForgotPassword::class) + ->set('email', 'admin@clusev.local') + ->call('sendResetLink') + ->assertHasNoErrors(); + + Notification::assertSentTo($user, ResetPasswordNotification::class); + } + + public function test_reset_password_with_token_sets_new_password(): void + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + Livewire::test(ResetPassword::class, ['token' => $token, 'email' => $user->email]) + ->set('password', 'NewPass1234')->set('password_confirmation', 'NewPass1234') + ->call('reset')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPass1234', $user->fresh()->password)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=EmailResetTest` +Expected: FAIL (`sendResetLink`/`ResetPassword` missing). + +- [ ] **Step 3: Add `sendResetLink()` to `ForgotPassword`** + +```php + /** Secondary path: email a reset link (only meaningful when a real mailer is set). */ + public function sendResetLink(): void + { + if (! $this->mailEnabled()) { + return; + } + + $this->validate(['email' => ['required', 'email']]); + \Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]); + + // Generic — never reveal whether the email exists. + $this->dispatch('notify', message: __('auth.reset_link_sent')); + } +``` + +- [ ] **Step 4: Create `app/Livewire/Auth/ResetPassword.php`** + +```php +token = $token; + $this->email = (string) request()->query('email', ''); + } + + public function reset() + { + $this->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()], + ]); + + $status = Password::reset( + ['email' => $this->email, 'password' => $this->password, 'password_confirmation' => $this->password_confirmation, 'token' => $this->token], + function ($user) { + $user->forceFill([ + 'password' => Hash::make($this->password), + 'must_change_password' => false, + 'remember_token' => Str::random(60), + ])->save(); + event(new PasswordReset($user)); + }, + ); + + if ($status !== Password::PasswordReset) { + throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]); + } + + session()->flash('status', __('auth.reset_done')); + + return $this->redirect(route('login'), navigate: true); + } + + public function render() + { + return view('livewire.auth.reset-password')->title(__('auth.title_forgot')); + } +} +``` + +- [ ] **Step 5: View + route + conditional button + lang** + +`resources/views/livewire/auth/reset-password.blade.php`: same field classes as forgot-password; fields email (readonly-ish), password, password_confirmation; `wire:submit="reset"`; submit `auth.forgot_submit`; back-to-login link. + +`routes/web.php` guest group: `Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset');` + +In `forgot-password.blade.php`, replace the `@if ($this->mailEnabled())` notice with an actionable button: +```blade +@if ($this->mailEnabled()) + {{ __('auth.forgot_send_email') }} +@endif +``` + +Lang — `lang/de/auth.php`: `'forgot_send_email' => 'Reset-Link per E-Mail senden'`, `'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.'`, `'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.'`. `lang/en/auth.php`: `'forgot_send_email' => 'Email me a reset link'`, `'reset_link_sent' => 'If the email exists, a reset link has been sent.'`, `'reset_token_invalid' => 'Reset link is invalid or expired.'`. (Remove the now-unused `forgot_email_alt` from Task 4 if present, or leave it unused.) + +- [ ] **Step 6: Run tests, Pint, build, commit** + +Run: `docker compose exec -T app php artisan test --filter=EmailResetTest` → PASS (3). +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +docker compose exec -T -u "1002:1002" app npm run build +git add app/Livewire/Auth/ResetPassword.php resources/views/livewire/auth/reset-password.blade.php app/Livewire/Auth/ForgotPassword.php resources/views/livewire/auth/forgot-password.blade.php routes/web.php lang/de/auth.php lang/en/auth.php tests/Feature/EmailResetTest.php +git commit -m "feat(auth): email reset-link path (gated on a configured mailer)" +``` + +--- + +### Task 5: CLI lockout fallback `clusev:reset-admin` + +**Files:** +- Create: `app/Console/Commands/ResetAdmin.php` +- Test: `tests/Feature/ResetAdminCommandTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +create([ + 'email' => 'admin@clusev.local', + 'two_factor_secret' => 'x', 'two_factor_confirmed_at' => now(), + ]); + $user->replaceRecoveryCodes(); + + $this->artisan('clusev:reset-admin', ['--email' => 'admin@clusev.local', '--password' => 'BrandNew1234', '--disable-2fa' => true]) + ->assertExitCode(0); + + $user->refresh(); + $this->assertTrue(Hash::check('BrandNew1234', $user->password)); + $this->assertNull($user->two_factor_secret); + $this->assertNull($user->two_factor_confirmed_at); + $this->assertFalse($user->hasRecoveryCodes()); + } + + public function test_unknown_email_fails(): void + { + $this->artisan('clusev:reset-admin', ['--email' => 'nope@x.test'])->assertExitCode(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `docker compose exec -T app php artisan test --filter=ResetAdminCommandTest` +Expected: FAIL (command missing). + +- [ ] **Step 3: Create `app/Console/Commands/ResetAdmin.php`** + +```php +option('email'); + $user = $email !== '' + ? User::where('email', $email)->first() + : (User::count() === 1 ? User::first() : null); + + if (! $user) { + $this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.'); + + return self::FAILURE; + } + + $password = (string) ($this->option('password') ?: Str::password(20)); + $user->forceFill(['password' => Hash::make($password), 'must_change_password' => false]); + + if ($this->option('disable-2fa')) { + $user->forceFill([ + 'two_factor_secret' => null, + 'two_factor_confirmed_at' => null, + 'two_factor_recovery_codes' => null, + ]); + } + + $user->save(); + + $this->info("Passwort für {$user->email} zurückgesetzt."); + if (! $this->option('password')) { + $this->warn("Einmal-Passwort (jetzt notieren): {$password}"); + } + if ($this->option('disable-2fa')) { + $this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.'); + } + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker compose exec -T app php artisan test --filter=ResetAdminCommandTest` +Expected: PASS (2). + +- [ ] **Step 5: Pint + commit** + +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add app/Console/Commands/ResetAdmin.php tests/Feature/ResetAdminCommandTest.php +git commit -m "feat(auth): clusev:reset-admin CLI lockout fallback" +``` + +--- + +### Task 6: Full verification (R12 + Codex) + +- [ ] **Step 1: Full suite + Pint** + +Run: `docker compose exec -T app php artisan test` (only the pre-existing ExampleTest may fail). +Run: `docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --test` + +- [ ] **Step 2: Build** + +Run: `docker compose exec -T -u "1002:1002" app npm run build` + +- [ ] **Step 3: R12 browser — DE + EN, 375 / 768 / 1280** + +Temp `/__peek` recipe (remove before commit). Flows: login shows "Passwort vergessen?" → forgot-password page renders + a wrong code shows the German error; 2FA setup → recovery-codes page shows 8 codes + download returns a .txt; settings → "Backup-Codes verwalten" link works. HTTP 200, 0 console, DOM scan (R17). + +- [ ] **Step 4: Codex review (R15)** + +Run: `export PATH="$HOME/.npm-global/bin:$PATH"; codex review --base ` — fix + re-run until clean. + +- [ ] **Step 5: Update handoff + release** + +Note the feature in `docs/session-handoff.md`. Release the version bump per the session cadence (separate commit + tag + sanitized push) when the user approves. + +--- + +## Self-Review + +**Spec coverage:** +- Data model (column + cast + 4 methods) → Task 1. ✓ +- 2FA backup codes: generation (Task 3), challenge use (Task 2), show/download/regenerate (Task 3), settings link (Task 3). ✓ +- Forgot-password 2FA-code primary → Task 4. ✓ +- Email-link secondary (gated on `mail.default`) → Task 4b (`sendResetLink` + `Auth\ResetPassword` + `/reset-password/{token}`), hidden in the UI while `MAIL_MAILER=log`, tested via `Notification::fake` + a `mail.default` override. ✓ +- CLI fallback → Task 5. ✓ +- i18n DE+EN → keys in Tasks 2-5. ✓ +- Testing + R12 + Codex → Tasks 1-6. ✓ + +**Placeholder scan:** none — every step has concrete code. + +**Type consistency:** `replaceRecoveryCodes()/recoveryCodes()/useRecoveryCode()/hasRecoveryCodes()` defined in Task 1, used in Tasks 2-5. Routes `two-factor.recovery`, `two-factor.recovery.download`, `password.request` consistent across tasks. `mailEnabled()` defined + used in Task 4. + +**Execution order:** Task 1 → 2 → 3 → 4 → 4b → 5 → 6. Tasks 4 and 4b both touch `ForgotPassword`/its view/`routes`/`auth.php`; finish + commit 4 before starting 4b.