# 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=<Name>`
- 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
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->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
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserRecoveryCodesTest extends TestCase
{
    use RefreshDatabase;

    public function test_replace_generates_eight_unique_codes_and_persists(): void
    {
        $user = User::factory()->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<int, string> 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<int, string> */
    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
<?php

namespace Tests\Feature;

use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class TwoFactorChallengeRecoveryTest extends TestCase
{
    use RefreshDatabase;

    private function enrolledUser(): User
    {
        return User::factory()->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
<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.challenge_recovery_hint') }}</p>
```

- [ ] **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
<?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();
        $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
<?php

namespace App\Livewire\Auth;

use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;

#[Layout('layouts.auth')]
class RecoveryCodes extends Component
{
    /** Regenerate the current user's backup codes (invalidates the old set). */
    public function regenerate(): void
    {
        Auth::user()->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
<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-2 gap-2 rounded-md border border-line bg-inset p-4">
        @foreach ($codes as $c)
            <span class="font-mono text-sm 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>
```

- [ ] **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
<x-btn href="{{ route('two-factor.recovery') }}" variant="secondary">{{ __('auth.recovery_manage') }}</x-btn>
```

`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
<?php

namespace Tests\Feature;

use App\Livewire\Auth\ForgotPassword;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;

class ForgotPasswordTest extends TestCase
{
    use RefreshDatabase;

    private function user(): User
    {
        return User::factory()->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
<?php

namespace App\Livewire\Auth;

use App\Models\AuditEvent;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
use PragmaRX\Google2FAQRCode\Google2FA;

#[Layout('layouts.auth')]
class ForgotPassword extends Component
{
    public string $email = '';

    public string $code = '';

    public string $password = '';

    public string $password_confirmation = '';

    /** True when a real mailer is configured (then the email-link option is offered too). */
    public function mailEnabled(): bool
    {
        return ! in_array(config('mail.default'), ['log', 'array', null], true);
    }

    /** Reset the password by proving 2FA possession (TOTP or a backup code). */
    public function reset()
    {
        $this->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
<div class="space-y-5">
    <div>
        <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.security') }}</p>
        <h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.forgot_heading') }}</h1>
        <p class="mt-1.5 text-sm text-ink-2">{{ __('auth.forgot_subtitle') }}</p>
    </div>

    <form wire:submit="reset" class="space-y-4">
        <div>
            <label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
            <input wire:model="email" id="email" type="email" autocomplete="username" class="{{ $field }}" placeholder="admin@clusev.local" />
            @error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
        </div>
        <div>
            <label for="code" class="{{ $label }}">{{ __('auth.forgot_code') }}</label>
            <input wire:model="code" id="code" type="text" inputmode="text" autocomplete="one-time-code" class="{{ $field }} font-mono" placeholder="123456 / xxxxx-xxxxx" />
            @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
            <p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.forgot_code_hint') }}</p>
        </div>
        <div>
            <label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
            <input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
            @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
        </div>
        <div>
            <label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label>
            <input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
        </div>
        <x-btn variant="primary" size="lg" type="submit" class="w-full">{{ __('auth.forgot_submit') }}</x-btn>
    </form>

    @if ($this->mailEnabled())
        <p class="text-center font-mono text-[11px] text-ink-4">{{ __('auth.forgot_email_alt') }}</p>
    @endif

    <a href="{{ route('login') }}" wire:navigate class="block text-center font-mono text-[11px] text-ink-3 hover:text-ink-2">{{ __('auth.back_to_login') }}</a>
</div>
```

- [ ] **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
<a href="{{ route('password.request') }}" wire:navigate class="mt-1.5 block font-mono text-[11px] text-ink-3 hover:text-accent-text">{{ __('auth.forgot_link') }}</a>
```

- [ ] **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
<?php

namespace Tests\Feature;

use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
use Tests\TestCase;

class EmailResetTest extends TestCase
{
    use RefreshDatabase;

    public function test_link_option_hidden_when_mailer_is_log(): void
    {
        config(['mail.default' => '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
<?php

namespace App\Livewire\Auth;

use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;

#[Layout('layouts.auth')]
class ResetPassword extends Component
{
    public string $token = '';

    public string $email = '';

    public string $password = '';

    public string $password_confirmation = '';

    public function mount(string $token): void
    {
        $this->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())
    <x-btn variant="secondary" class="w-full" wire:click="sendResetLink">{{ __('auth.forgot_send_email') }}</x-btn>
@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
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class ResetAdminCommandTest extends TestCase
{
    use RefreshDatabase;

    public function test_resets_password_and_disables_2fa(): void
    {
        $user = User::factory()->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
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class ResetAdmin extends Command
{
    protected $signature = 'clusev:reset-admin
        {--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
        {--password= : Neues Passwort (zufällig erzeugt, falls leer)}
        {--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';

    protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';

    public function handle(): int
    {
        $email = (string) $this->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 <pre-feature-sha>` — 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.
