# Optional, Pluggable 2FA Implementation Plan

> **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:** Make 2FA optional and pluggable — the operator secures the account with TOTP, a security key (WebAuthn), both, or nothing — replacing forced-TOTP onboarding and the dedicated recovery-codes page (now a modal).

**Architecture:** `User` gains a per-factor predicate (`hasTotp()`) while `hasTwoFactorEnabled()` becomes "either factor". Onboarding stops forcing 2FA (only the password rotation stays forced). Recovery codes are generated on the first factor, cleared on the last, shown in a `wire-elements/modal`. The login challenge and Settings adapt to whichever factor(s) exist.

**Tech Stack:** Laravel 13, Livewire 3 (class-based), wire-elements/modal, pragmarx/google2fa-qrcode (TOTP), web-auth/webauthn-lib (WebAuthn), Tailwind v4, bilingual `lang/{de,en}` (R16).

**Spec:** `docs/superpowers/specs/2026-06-14-optional-pluggable-2fa-design.md`

---

## Conventions for every task

- Run all tooling in the container: `docker compose exec -T app php artisan test --filter <Name>`, `docker compose exec -T app ./vendor/bin/pint <files>`.
- UI strings are localized in **both** `lang/de/*` and `lang/en/*` with identical keys (R16); no emoji (R9).
- Destructive confirmations use `wire-elements/modal` (R5), never `confirm()`/`wire:confirm`.
- After the last task: rebuild assets, R12 browser verify (bare-IP, DE+EN), Codex review (R15) clean, then release.

## File Structure

| File | Responsibility | Change |
|---|---|---|
| `app/Models/User.php` | Factor predicates + recovery-code lifecycle | add `hasTotp()`, redefine `hasTwoFactorEnabled()`/`securityOnboarded()`, add `resetIfNoFactor()` |
| `app/Http/Middleware/EnsureSecurityOnboarded.php` | Onboarding gate | drop the 2FA redirect |
| `app/Livewire/Auth/PasswordChange.php` | Forced password rotation | redirect to dashboard |
| `app/Livewire/Auth/TwoFactorSetup.php` | TOTP enrollment | app layout, guard on `hasTotp()`, generate codes + open modal |
| `app/Livewire/Auth/TwoFactorChallenge.php` | Login second step | TOTP check only when `hasTotp()`; field gated |
| `app/Livewire/Auth/ForgotPassword.php` | Password recovery proof | TOTP check only when `hasTotp()` |
| `app/Livewire/Settings/Index.php` | Manage TOTP factor | remove-TOTP always allowed + `resetIfNoFactor()`; hint flag |
| `app/Livewire/Settings/WebauthnKeys.php` | Manage keys | gate on `available()` only; first-key codes; last-key reset |
| `app/Livewire/Modals/RecoveryCodes.php` (NEW) | Recovery-codes modal | show/download/regenerate |
| `resources/views/livewire/modals/recovery-codes.blade.php` (NEW) | Modal view | — |
| `app/Livewire/Auth/RecoveryCodes.php` (DELETE) | Old full-page recovery | removed |
| `resources/views/livewire/auth/recovery-codes.blade.php` (DELETE) | Old view | removed |
| `routes/web.php` | Routes | remove `two-factor.recovery`, keep download |
| `resources/views/livewire/auth/two-factor-challenge.blade.php` | Challenge UI | gate TOTP field |
| `resources/views/livewire/settings/index.blade.php` | Security tab | modal trigger, hint, TOTP remove |
| `resources/views/livewire/auth/forgot-password.blade.php` | Forgot copy | no-2FA note |
| `resources/views/livewire/auth/two-factor-setup.blade.php` | Setup page | (works under app layout) |
| `lang/{de,en}/auth.php`, `lang/{de,en}/settings.php` | Strings | new keys |

---

### Task 1: `User` factor semantics + recovery lifecycle

**Files:**
- Modify: `app/Models/User.php:85-98`
- Test: `tests/Feature/UserFactorSemanticsTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/UserFactorSemanticsTest.php`:

```php
<?php

namespace Tests\Feature;

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

class UserFactorSemanticsTest extends TestCase
{
    use RefreshDatabase;

    private function totpUser(): User
    {
        return User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
    }

    private function keyUser(): User
    {
        $u = User::factory()->create();
        WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);

        return $u->fresh();
    }

    public function test_has_totp_only_reflects_totp(): void
    {
        $this->assertTrue($this->totpUser()->hasTotp());
        $this->assertFalse($this->keyUser()->hasTotp());
    }

    public function test_has_two_factor_enabled_is_either_factor(): void
    {
        $this->assertTrue($this->totpUser()->hasTwoFactorEnabled());
        $this->assertTrue($this->keyUser()->hasTwoFactorEnabled());
        $this->assertFalse(User::factory()->create()->hasTwoFactorEnabled());
    }

    public function test_security_onboarded_is_password_rotation_only(): void
    {
        $this->assertTrue(User::factory()->create(['must_change_password' => false])->securityOnboarded());
        $this->assertFalse(User::factory()->create(['must_change_password' => true])->securityOnboarded());
    }

    public function test_reset_if_no_factor_clears_codes_only_when_no_factor_remains(): void
    {
        $totp = $this->totpUser();
        $totp->replaceRecoveryCodes();
        $totp->resetIfNoFactor();
        $this->assertTrue($totp->fresh()->hasRecoveryCodes(), 'codes kept while a factor remains');

        $none = User::factory()->create();
        $none->replaceRecoveryCodes();
        $none->resetIfNoFactor();
        $this->assertFalse($none->fresh()->hasRecoveryCodes(), 'codes cleared when no factor remains');
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest`
Expected: FAIL — `hasTotp()` / `resetIfNoFactor()` undefined.

- [ ] **Step 3: Implement the semantics**

In `app/Models/User.php`, replace the `hasTwoFactorEnabled()` + `securityOnboarded()` block (lines 85-98) with:

```php
    /** TOTP factor only: an authenticator secret was confirmed. */
    public function hasTotp(): bool
    {
        return ! is_null($this->two_factor_confirmed_at) && ! is_null($this->two_factor_secret);
    }

    /** 2FA is satisfied by EITHER factor — TOTP or a registered security key. */
    public function hasTwoFactorEnabled(): bool
    {
        return $this->hasTotp() || $this->hasWebauthnCredentials();
    }

    /**
     * Completed the security onboarding. 2FA is now OPTIONAL, so this is just the forced
     * password rotation — the gate EnsureSecurityOnboarded and the broadcast channels use.
     */
    public function securityOnboarded(): bool
    {
        return ! $this->must_change_password;
    }

    /**
     * When neither factor remains (no TOTP, no keys), 2FA is fully off — so drop the backup
     * codes (nothing left to recover into). Call after removing any factor.
     */
    public function resetIfNoFactor(): void
    {
        if (! $this->hasTotp() && ! $this->hasWebauthnCredentials()) {
            $this->forceFill(['two_factor_recovery_codes' => null])->save();
        }
    }
```

- [ ] **Step 4: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter UserFactorSemanticsTest`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Models/User.php tests/Feature/UserFactorSemanticsTest.php
git add app/Models/User.php tests/Feature/UserFactorSemanticsTest.php
git commit -m "feat(2fa): pluggable factor semantics on User (hasTotp, either-factor, resetIfNoFactor)"
```

---

### Task 2: Onboarding stops forcing 2FA

**Files:**
- Modify: `app/Http/Middleware/EnsureSecurityOnboarded.php:20-28`
- Modify: `app/Livewire/Auth/PasswordChange.php:35-36`
- Test: `tests/Feature/OptionalOnboardingTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/OptionalOnboardingTest.php`:

```php
<?php

namespace Tests\Feature;

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

class OptionalOnboardingTest extends TestCase
{
    use RefreshDatabase;

    public function test_rotated_user_without_2fa_reaches_the_dashboard(): void
    {
        $user = User::factory()->create(['must_change_password' => false]);

        $this->actingAs($user)->get('/')->assertOk();
    }

    public function test_user_that_must_change_password_is_still_redirected(): void
    {
        $user = User::factory()->create(['must_change_password' => true]);

        $this->actingAs($user)->get('/')->assertRedirect(route('password.change'));
    }

    public function test_password_change_lands_on_dashboard_not_2fa_setup(): void
    {
        $user = User::factory()->create(['must_change_password' => true, 'password' => bcrypt('old-Password-1')]);

        Livewire::actingAs($user)->test(PasswordChange::class)
            ->set('current', 'old-Password-1')
            ->set('password', 'new-Password-123')
            ->set('password_confirmation', 'new-Password-123')
            ->call('update')
            ->assertRedirect(route('dashboard'));
    }

    public function test_broadcast_gate_passes_on_password_rotation_alone(): void
    {
        $user = User::factory()->create(['must_change_password' => false]);

        $this->assertTrue($user->securityOnboarded());
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest`
Expected: FAIL — `/` redirects an un-enrolled user to `two-factor.setup`; PasswordChange redirects to `two-factor.setup`.

- [ ] **Step 3: Drop the 2FA redirect in the middleware**

In `app/Http/Middleware/EnsureSecurityOnboarded.php`, replace the body of `handle()` (lines 17-31) with:

```php
        $user = $request->user();

        if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) {
            return redirect()->route('password.change');
        }

        return $next($request);
```

Also update the class docblock (lines 9-12) to:

```php
/**
 * After login, force the seeded-password rotation before the panel is reachable.
 * 2FA is optional (offered in Settings), so it is NOT forced here.
 */
```

- [ ] **Step 4: Redirect PasswordChange to the dashboard**

In `app/Livewire/Auth/PasswordChange.php`, replace lines 35-36:

```php
        // 2FA is optional now — go straight to the panel; 2FA is offered in Settings.
        return $this->redirect(route('dashboard'), navigate: true);
```

- [ ] **Step 5: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter OptionalOnboardingTest`
Expected: PASS (4 tests).

- [ ] **Step 6: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php
git add app/Http/Middleware/EnsureSecurityOnboarded.php app/Livewire/Auth/PasswordChange.php tests/Feature/OptionalOnboardingTest.php
git commit -m "feat(2fa): onboarding no longer forces 2FA (only password rotation)"
```

---

### Task 3: Recovery-codes MODAL (replace the full-page view)

**Files:**
- Create: `app/Livewire/Modals/RecoveryCodes.php`
- Create: `resources/views/livewire/modals/recovery-codes.blade.php`
- Delete: `app/Livewire/Auth/RecoveryCodes.php`, `resources/views/livewire/auth/recovery-codes.blade.php`
- Modify: `routes/web.php:58` (remove `two-factor.recovery` route; keep download at 59-67)
- Modify: `lang/de/auth.php`, `lang/en/auth.php` (modal title key)
- Test: `tests/Feature/RecoveryCodesModalTest.php` (Create)
- Delete test: `tests/Feature/RecoveryCodesViewTest.php`

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/RecoveryCodesModalTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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(\Illuminate\Support\Facades\Route::has('two-factor.recovery'));
        $this->assertTrue(\Illuminate\Support\Facades\Route::has('two-factor.recovery.download'));
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest`
Expected: FAIL — `App\Livewire\Modals\RecoveryCodes` does not exist; `two-factor.recovery` still registered.

- [ ] **Step 3: Create the modal component**

Create `app/Livewire/Modals/RecoveryCodes.php`:

```php
<?php

namespace App\Livewire\Modals;

use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;

/**
 * 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
    {
        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.modals.recovery-codes', ['codes' => $this->codes()]);
    }
}
```

- [ ] **Step 4: Create the modal view**

Create `resources/views/livewire/modals/recovery-codes.blade.php`:

```blade
<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>
```

- [ ] **Step 5: Remove the old full-page route**

In `routes/web.php`, delete line 58 (the `two-factor.recovery` GET route) and adjust the comment block (lines 56-57) to:

```php
    // 2FA backup-codes download — the codes themselves are shown in a modal (Modals\RecoveryCodes).
```

Keep the download route (lines 59-67) intact.

- [ ] **Step 6: Delete the old full-page component, view, and its test**

```bash
git rm app/Livewire/Auth/RecoveryCodes.php resources/views/livewire/auth/recovery-codes.blade.php tests/Feature/RecoveryCodesViewTest.php
```

- [ ] **Step 7: Add the modal title key (kept for parity; reuse existing recovery_* keys)**

No new key is strictly required — the modal reuses `auth.recovery_heading/subtitle/warning/download/regenerate/done`. Verify `lang/de/auth.php` and `lang/en/auth.php` still contain them (they do, lines ~78-85). Remove the now-unused `auth.title_recovery` key from both files (it titled the deleted page):

In `lang/de/auth.php` and `lang/en/auth.php`, delete the `'title_recovery' => …` line.

- [ ] **Step 8: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter RecoveryCodesModalTest`
Expected: PASS (3 tests).

- [ ] **Step 9: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Livewire/Modals/RecoveryCodes.php tests/Feature/RecoveryCodesModalTest.php
git add -A
git commit -m "feat(2fa): recovery codes become a modal; drop the dedicated page + route"
```

---

### Task 4: First-factor code generation + open the modal

**Files:**
- Modify: `app/Livewire/Auth/TwoFactorSetup.php` (layout, mount guard, confirm flow)
- Modify: `app/Livewire/Settings/WebauthnKeys.php:28-47` (first-key codes)
- Modify: `app/Livewire/Settings/Index.php` (mount reads the flash to open the modal)
- Modify: `resources/views/livewire/settings/index.blade.php` (x-init opens modal on flash)
- Test: `tests/Feature/FirstFactorCodesTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/FirstFactorCodesTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Livewire\Auth\TwoFactorSetup;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;

class FirstFactorCodesTest extends TestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_first_totp_confirm_generates_codes_and_opens_modal(): void
    {
        $user = User::factory()->create();
        $g = new Google2FA;
        $secret = $g->generateSecretKey();

        $component = Livewire::actingAs($user)->test(TwoFactorSetup::class);
        $component->set('secret', $secret)
            ->set('code', $g->getCurrentOtp($secret))
            ->call('confirm')
            ->assertRedirect(route('settings'));

        $this->assertTrue($user->fresh()->hasRecoveryCodes());
        $this->assertTrue(session('open_recovery_modal'));
    }

    public function test_first_key_register_generates_codes_and_opens_modal(): void
    {
        $user = User::factory()->create();

        $svc = Mockery::mock(WebauthnService::class);
        $svc->shouldReceive('available')->andReturnTrue();
        $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $a, $name) => WebauthnCredential::create([
            'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0,
        ]));
        app()->instance(WebauthnService::class, $svc);

        Livewire::actingAs($user)->test(WebauthnKeys::class)
            ->set('newName', 'YubiKey 5C')
            ->call('register', ['dummy' => true], $svc)
            ->assertDispatched('openModal');

        $this->assertTrue($user->fresh()->hasRecoveryCodes());
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest`
Expected: FAIL — TOTP confirm redirects to the removed `two-factor.recovery` route; key register does not generate codes nor dispatch `openModal`.

- [ ] **Step 3: Rework TwoFactorSetup**

In `app/Livewire/Auth/TwoFactorSetup.php`:

Change the layout attribute (line 12) to:

```php
#[Layout('layouts.app')]
```

Change `mount()` (lines 20-27) — guard on `hasTotp()` so a key-only user can still add TOTP:

```php
    public function mount()
    {
        if (Auth::user()->hasTotp()) {
            return $this->redirect(route('settings'), navigate: true);
        }

        $this->secret = (new Google2FA)->generateSecretKey();
    }
```

Change `confirm()` (lines 29-46) — generate codes only if none exist, flash the modal flag, redirect to Settings:

```php
    public function confirm()
    {
        $this->validate();

        if (! (new Google2FA)->verifyKey($this->secret, preg_replace('/\s+/', '', $this->code))) {
            throw ValidationException::withMessages(['code' => __('auth.code_mismatch')]);
        }

        Auth::user()->forceFill([
            'two_factor_secret' => $this->secret,
            'two_factor_confirmed_at' => now(),
        ])->save();

        // First factor enrolled and no codes yet → generate the single recovery set and
        // ask Settings to pop the modal (the app layout hosts wire-elements/modal).
        if (! Auth::user()->hasRecoveryCodes()) {
            Auth::user()->replaceRecoveryCodes();
            session()->flash('open_recovery_modal', true);
        }

        return $this->redirect(route('settings'), navigate: true);
    }
```

- [ ] **Step 4: Generate codes on the first key in WebauthnKeys::register()**

In `app/Livewire/Settings/WebauthnKeys.php`, inside `register()` after the credential is created (after line 35 `$this->reset('newName');`), insert:

```php
        // First factor enrolled and no codes yet → generate + show the recovery modal.
        if (! Auth::user()->hasRecoveryCodes()) {
            Auth::user()->replaceRecoveryCodes();
            $this->dispatch('openModal', component: 'modals.recovery-codes');
        }
```

- [ ] **Step 5: Settings opens the modal when the flash is set**

In `app/Livewire/Settings/Index.php`, extend `mount()` (lines 28-32) to land on the security tab and expose the flash:

```php
    public bool $openRecoveryModal = false;

    public function mount(): void
    {
        $this->name = Auth::user()->name;
        $this->email = Auth::user()->email;

        if (session('open_recovery_modal')) {
            $this->tab = 'security';
            $this->openRecoveryModal = true;
        }
    }
```

(Add the `public bool $openRecoveryModal = false;` property near the other public props, above `mount()`.)

- [ ] **Step 6: The security tab dispatches openModal via x-init when flagged**

In `resources/views/livewire/settings/index.blade.php`, immediately after the opening `<div class="space-y-5">` (line 13), add:

```blade
    @if ($openRecoveryModal)
        <div x-data x-init="$dispatch('openModal', { component: 'modals.recovery-codes' })"></div>
    @endif
```

- [ ] **Step 7: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter FirstFactorCodesTest`
Expected: PASS (2 tests).

- [ ] **Step 8: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorSetup.php app/Livewire/Settings/WebauthnKeys.php app/Livewire/Settings/Index.php tests/Feature/FirstFactorCodesTest.php
git add -A
git commit -m "feat(2fa): generate recovery codes on the first factor + open the modal"
```

---

### Task 5: Login challenge adapts to whichever factor exists

**Files:**
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php:47-49` (verify) + add `hasTotp()` helper for the view
- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` (gate the TOTP field)
- Test: `tests/Feature/ChallengeFactorAdaptTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/ChallengeFactorAdaptTest.php`:

```php
<?php

namespace Tests\Feature;

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

class ChallengeFactorAdaptTest extends TestCase
{
    use RefreshDatabase;

    private function keyOnlyUser(): User
    {
        $u = User::factory()->create();
        WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);

        return $u->fresh();
    }

    public function test_key_only_user_has_no_totp_field_but_a_backup_code_works(): void
    {
        $user = $this->keyOnlyUser();
        $codes = $user->replaceRecoveryCodes();
        session(['2fa.user' => $user->id, '2fa.remember' => false]);

        Livewire::test(TwoFactorChallenge::class)
            ->assertSet('pendingHasTotp', false)
            ->set('code', $codes[0])
            ->call('verify')
            ->assertRedirect(route('dashboard'));

        $this->assertAuthenticatedAs($user);
    }

    public function test_totp_user_sees_the_field(): void
    {
        $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
        session(['2fa.user' => $user->id, '2fa.remember' => false]);

        Livewire::test(TwoFactorChallenge::class)->assertSet('pendingHasTotp', true);
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest`
Expected: FAIL — `pendingHasTotp` is not defined; `verify()` calls `Google2FA::verifyKey()` with a null secret for the key-only user (error/false before the backup-code fallback is reached safely).

- [ ] **Step 3: Guard the TOTP check + expose the predicate**

In `app/Livewire/Auth/TwoFactorChallenge.php`:

Add a public computed property for the view. After `mount()` (line 26), add:

```php
    /** Whether the PENDING user has TOTP — the code field renders only then. */
    public function getPendingHasTotpProperty(): bool
    {
        return (bool) User::find(session('2fa.user'))?->hasTotp();
    }
```

Replace the TOTP-or-backup check (lines 47-49) with a guarded version:

```php
        // Accept the TOTP code only when the pending user actually has TOTP, otherwise a
        // null secret would break verifyKey; always allow a one-time backup (recovery) code.
        $valid = ($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, preg_replace('/\s+/', '', $this->code)))
            || $user->useRecoveryCode($this->code);
```

> Note: Livewire exposes `getPendingHasTotpProperty()` to Blade as `$pendingHasTotp` and to `assertSet('pendingHasTotp', …)`.

- [ ] **Step 4: Gate the TOTP field in the view**

In `resources/views/livewire/auth/two-factor-challenge.blade.php`, wrap the code `<input>`/label block in `@if ($this->pendingHasTotp) … @endif`, and make the backup-code hint always available. Read the file first; the field is the `wire:model="code"` input group. Replace its surrounding block with:

```blade
        @if ($this->pendingHasTotp)
            {{-- existing TOTP code label + input group, unchanged --}}
        @endif
```

Keeping: the "Mit Security-Key anmelden" button (already gated by `$this->webauthnAvailable()`), and the backup-code hint (`auth.challenge_recovery_hint`). For a key-only user the `code` input must still be reachable to type a backup code — if the only input was inside the `@if`, add a single shared code input shown when `! $this->pendingHasTotp` too, labelled with `auth.challenge_recovery_hint`. Concretely, structure the form as:

```blade
        <div>
            <label class="{{ $label }}">
                {{ $this->pendingHasTotp ? __('auth.challenge_code_label') : __('auth.challenge_backup_label') }}
            </label>
            <input wire:model="code" inputmode="numeric" autocomplete="one-time-code" autofocus
                   placeholder="{{ $this->pendingHasTotp ? __('auth.challenge_code_placeholder') : __('auth.challenge_backup_placeholder') }}"
                   class="{{ $field }} font-mono tracking-[0.3em]" />
            @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
        </div>
```

> The single `code` input serves TOTP for a TOTP user and a backup code for a key-only user; `verify()` already tries both paths. Match the existing `$label`/`$field`/`$err` locals at the top of the view (read it; they mirror the login view). If those locals are absent, reuse the classes already present on the existing input.

- [ ] **Step 5: Add the new challenge label keys (de + en)**

In `lang/de/auth.php` add (near the other `challenge_*` keys):

```php
    'challenge_code_label' => 'Authenticator-Code',
    'challenge_code_placeholder' => '123456',
    'challenge_backup_label' => 'Backup-Code',
    'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx',
```

In `lang/en/auth.php` add the same keys:

```php
    'challenge_code_label' => 'Authenticator code',
    'challenge_code_placeholder' => '123456',
    'challenge_backup_label' => 'Backup code',
    'challenge_backup_placeholder' => 'xxxxxxxxxx-xxxxxxxxxx',
```

(If `challenge_code_label`/`challenge_code_placeholder` already exist in the file, reuse them and only add the two `challenge_backup_*` keys.)

- [ ] **Step 6: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter ChallengeFactorAdaptTest`
Expected: PASS (2 tests).

- [ ] **Step 7: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php
git add -A
git commit -m "feat(2fa): challenge adapts to factor — TOTP field gated, key-only uses backup code"
```

---

### Task 6: Settings — manage factors independently

**Files:**
- Modify: `app/Livewire/Settings/Index.php:77-90` (disableTwoFactor → remove TOTP + reset)
- Modify: `app/Livewire/Settings/WebauthnKeys.php:20,32,97` (gate on `available()` only; last-key reset)
- Modify: `resources/views/livewire/settings/index.blade.php` (TOTP card always removable, "empfohlen" hint, recovery modal trigger)
- Modify: `lang/{de,en}/settings.php` (hint + TOTP-remove keys)
- Test: `tests/Feature/SettingsFactorManagementTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/SettingsFactorManagementTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Livewire\Settings\Index;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;

class SettingsFactorManagementTest extends TestCase
{
    use RefreshDatabase;

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_removing_totp_when_it_is_the_only_factor_clears_codes(): void
    {
        $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
        $user->replaceRecoveryCodes();

        Livewire::actingAs($user)->test(Index::class)->call('disableTwoFactor');

        $fresh = $user->fresh();
        $this->assertFalse($fresh->hasTotp());
        $this->assertFalse($fresh->hasRecoveryCodes(), 'last factor removed → codes cleared');
    }

    public function test_removing_totp_keeps_codes_when_a_key_remains(): void
    {
        $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]);
        WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
        $user->replaceRecoveryCodes();

        Livewire::actingAs($user->fresh())->test(Index::class)->call('disableTwoFactor');

        $fresh = $user->fresh();
        $this->assertFalse($fresh->hasTotp());
        $this->assertTrue($fresh->hasWebauthnCredentials());
        $this->assertTrue($fresh->hasRecoveryCodes(), 'key remains → codes kept');
    }

    public function test_a_key_can_be_the_first_factor_without_existing_2fa(): void
    {
        $user = User::factory()->create(); // no TOTP, no key

        $svc = Mockery::mock(WebauthnService::class);
        $svc->shouldReceive('available')->andReturnTrue();
        $svc->shouldReceive('registrationOptions')->andReturn(['ok' => true]);
        app()->instance(WebauthnService::class, $svc);

        // options() must NOT 404 just because the user has no 2FA yet.
        Livewire::actingAs($user)->test(WebauthnKeys::class)
            ->set('newName', 'YubiKey')
            ->call('options', $svc)
            ->assertReturned(['ok' => true]);
    }

    public function test_removing_the_last_key_clears_codes(): void
    {
        $user = User::factory()->create();
        $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
        $user->replaceRecoveryCodes();

        $svc = Mockery::mock(WebauthnService::class);
        $svc->shouldReceive('available')->andReturnTrue();
        app()->instance(WebauthnService::class, $svc);

        Livewire::actingAs($user->fresh())->test(WebauthnKeys::class)->call('remove', $cred->id, $svc);

        $this->assertFalse($user->fresh()->hasRecoveryCodes());
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest`
Expected: FAIL — `disableTwoFactor` nukes keys + does not reset codes conditionally; `WebauthnKeys::options()`/`register()` 404 without `hasTwoFactorEnabled()`; `remove()` does not reset codes.

- [ ] **Step 3: Reframe `disableTwoFactor` to remove only TOTP**

In `app/Livewire/Settings/Index.php`, replace `disableTwoFactor()` (lines 77-90) with:

```php
    #[On('twoFactorDisabled')]
    public function disableTwoFactor(): void
    {
        // Remove the TOTP factor only — security keys are managed on their own card. If no
        // factor remains afterwards, the backup codes are dropped too.
        Auth::user()->forceFill([
            'two_factor_secret' => null,
            'two_factor_confirmed_at' => null,
        ])->save();

        Auth::user()->resetIfNoFactor();
    }
```

- [ ] **Step 4: Gate WebauthnKeys on `available()` only + reset on last-key removal**

In `app/Livewire/Settings/WebauthnKeys.php`:

`options()` line 20 — drop the 2FA requirement:

```php
        abort_unless($webauthn->available(), 404);
```

`register()` line 32 — same:

```php
        abort_unless($webauthn->available(), 404);
```

`remove()` — after the credential is deleted and audited (after line 90 `$this->dispatch('notify', …)`), add the reset:

```php
            Auth::user()->resetIfNoFactor();
```

`render()` line 97 — the key card is available purely on the service gate now:

```php
            'available' => app(WebauthnService::class)->available(),
```

- [ ] **Step 5: Settings security tab — TOTP always removable, hint, modal trigger**

In `resources/views/livewire/settings/index.blade.php`, replace the 2FA panel block (lines 99-119) with:

```blade
                <x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
                    @unless ($twoFactorEnabled)
                        <div class="mb-4 flex items-start gap-2.5 rounded-md border border-accent/25 bg-accent/10 px-4 py-3">
                            <x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
                            <p class="text-sm text-ink-2">{{ __('settings.twofa_recommended') }}</p>
                        </div>
                    @endunless

                    <div class="flex flex-wrap items-center justify-between gap-3">
                        <div class="flex items-center gap-3">
                            <x-status-dot :status="$hasTotp ? 'online' : 'offline'" />
                            <div>
                                <p class="text-sm text-ink">{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
                                <p class="font-mono text-[11px] text-ink-3">{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
                            </div>
                        </div>
                        <div class="flex items-center gap-2">
                            @if ($twoFactorEnabled)
                                <x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
                            @endif
                            @if ($hasTotp)
                                <x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
                            @else
                                <x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
                                    <x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
                                </x-btn>
                            @endif
                        </div>
                    </div>
                </x-panel>

                <livewire:settings.webauthn-keys />
```

- [ ] **Step 6: Pass `hasTotp` into the Settings view**

In `app/Livewire/Settings/Index.php`, `render()` — add `hasTotp` alongside `twoFactorEnabled`:

```php
        return view('livewire.settings.index', [
            'twoFactorEnabled' => Auth::user()->hasTwoFactorEnabled(),
            'hasTotp' => Auth::user()->hasTotp(),
        ])->title(__('settings.title'));
```

- [ ] **Step 7: Add the new settings keys (de + en)**

In `lang/de/settings.php` add:

```php
    'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.',
    'twofa_remove_totp' => 'Authenticator entfernen',
```

In `lang/en/settings.php` add:

```php
    'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.',
    'twofa_remove_totp' => 'Remove authenticator',
```

- [ ] **Step 8: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter SettingsFactorManagementTest`
Expected: PASS (4 tests).

- [ ] **Step 9: Update the disable-2FA confirm copy (it no longer nukes keys)**

In `lang/de/settings.php` and `lang/en/settings.php`, soften `disable_2fa_heading`/`disable_2fa_body`/`disable_2fa_notify` to refer to the **authenticator** only (not "2FA"), e.g. DE `disable_2fa_heading` → `'Authenticator entfernen'`, `disable_2fa_body` → `'Den Authenticator (TOTP) als Faktor entfernen? Security-Keys bleiben bestehen.'`. Mirror in EN. (Read both files; keep keys, change values.)

- [ ] **Step 10: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Livewire/Settings/Index.php app/Livewire/Settings/WebauthnKeys.php tests/Feature/SettingsFactorManagementTest.php
git add -A
git commit -m "feat(2fa): Settings manages TOTP + keys independently; key can be the only factor; empfohlen hint"
```

---

### Task 7: forgot-password — key-only safe + no-2FA copy

**Files:**
- Modify: `app/Livewire/Auth/ForgotPassword.php:66-68` (guard verifyKey on hasTotp)
- Modify: `resources/views/livewire/auth/forgot-password.blade.php` (no-2FA note)
- Modify: `lang/{de,en}/auth.php` (no-2FA reset note)
- Test: `tests/Feature/ForgotPasswordKeyOnlyTest.php` (Create)

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/ForgotPasswordKeyOnlyTest.php`:

```php
<?php

namespace Tests\Feature;

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

class ForgotPasswordKeyOnlyTest extends TestCase
{
    use RefreshDatabase;

    public function test_key_only_user_resets_with_a_backup_code(): void
    {
        $user = User::factory()->create(['must_change_password' => false]);
        WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
        $codes = $user->fresh()->replaceRecoveryCodes();

        Livewire::test(ForgotPassword::class)
            ->set('email', $user->email)
            ->set('code', $codes[0])
            ->set('password', 'brand-New-Pass-9')
            ->set('password_confirmation', 'brand-New-Pass-9')
            ->call('resetPassword')
            ->assertRedirect(route('login'));

        $this->assertTrue(\Illuminate\Support\Facades\Hash::check('brand-New-Pass-9', $user->fresh()->password));
    }
}
```

- [ ] **Step 2: Run it to confirm it fails**

Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest`
Expected: FAIL — `Google2FA::verifyKey()` is called with the key-only user's null `two_factor_secret` before the backup-code fallback, throwing or short-circuiting.

- [ ] **Step 3: Guard the TOTP check**

In `app/Livewire/Auth/ForgotPassword.php`, replace the `$ok` assignment (lines 66-68) with:

```php
        $ok = $user
            && $user->hasTwoFactorEnabled()
            && (($user->hasTotp() && (new Google2FA)->verifyKey($user->two_factor_secret, $clean))
                || $user->useRecoveryCode($this->code));
```

- [ ] **Step 4: Add the no-2FA reset note to the view + lang**

In `resources/views/livewire/auth/forgot-password.blade.php`, add a small hint near the form footer:

```blade
        <p class="font-mono text-[11px] text-ink-4">{{ __('auth.reset_no_2fa_note') }}</p>
```

In `lang/de/auth.php`:

```php
    'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.',
```

In `lang/en/auth.php`:

```php
    'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.',
```

- [ ] **Step 5: Run the test to confirm it passes**

Run: `docker compose exec -T app php artisan test --filter ForgotPasswordKeyOnlyTest`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
docker compose exec -T app ./vendor/bin/pint app/Livewire/Auth/ForgotPassword.php tests/Feature/ForgotPasswordKeyOnlyTest.php
git add -A
git commit -m "feat(2fa): forgot-password is key-only safe + states the no-2FA recovery path"
```

---

### Task 8: Regression sweep, R12 browser verify, Codex, release

**Files:**
- Modify (as needed): the existing 2FA tests whose assumptions changed
- Modify: `config/clusev.php:6` (version bump)
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Run the full suite and fix fallout**

Run: `docker compose exec -T app php artisan test`
Expected: green. Likely files needing small updates because semantics changed:
- `tests/Feature/WebauthnKeysTest.php`, `tests/Feature/WebauthnOptionsTest.php`, `tests/Feature/WebauthnAvailableTest.php`: any test that set up `two_factor_confirmed_at`/secret purely to satisfy the old `hasTwoFactorEnabled()` gate on `options()`/`register()` can drop that setup (the gate is now `available()` only). Where a test asserted a 404 for a key attempt without TOTP, invert it to expect success.
- `tests/Feature/TwoFactorChallengeRecoveryTest.php`, `tests/Feature/TwoFactorWebauthnTest.php`: should still pass; if any asserts the challenge view contains the TOTP field for a key-only user, update to the new gated behaviour.
- Fix each failure by reading the test and aligning it with the spec (TOTP-only field, either-factor `hasTwoFactorEnabled`, codes lifecycle). Do not weaken security assertions.

- [ ] **Step 2: Build assets**

Run: `docker compose run --rm --no-deps -u "${HOST_UID}:${HOST_GID}" app npm run build`
Expected: `✓ built`.

- [ ] **Step 3: R12 browser verification (bare-IP, DE + EN)**

With a connected browser at `http://10.10.90.136`, verify at 375/768/1280:
- Login as the admin → after password rotation a no-2FA account reaches the **dashboard** (no forced 2FA).
- `/settings` → Security tab: the **"2FA empfohlen"** hint shows; the **TOTP card** offers *Einrichten*; the **Security-Keys** card shows the domain-unavailable hint on bare IP.
- Enroll TOTP at `/two-factor-setup` → on confirm you land on **Settings** and the **recovery-codes modal** pops with 8 codes + Download + Neu erzeugen + Gespeichert.
- After enrolling, the TOTP card shows *Authenticator entfernen*; **Backup-Codes verwalten** opens the modal.
- Remove the authenticator → codes cleared, hint returns.
- Switch locale (DE↔EN) and re-check the same screens for leaked `group.key`/`@`/`{{ }}` tokens (R17) and HTTP 200 + zero console errors.
- Log out, log in again with TOTP → the **challenge** shows the Authenticator field; entering a backup code also works.

Use the browser tools to confirm zero console errors on each touched route.

- [ ] **Step 4: Codex review (R15)**

Run a Codex review over the full diff (`git diff main...HEAD` or the working branch range). Fix every P1 and security finding; re-run until clean.

- [ ] **Step 5: Version bump + CHANGELOG**

In `config/clusev.php` line 6, bump `'version' => '0.7.0'` (new feature). Add a `## v0.7.0` section to `CHANGELOG.md` summarizing: optional/pluggable 2FA (TOTP and/or WebAuthn, or off), recovery codes as a modal, onboarding no longer forces 2FA.

- [ ] **Step 6: Final suite + commit + finish**

```bash
docker compose exec -T app php artisan test
docker compose exec -T app ./vendor/bin/pint
git add -A
git commit -m "release: optional pluggable 2FA (v0.7.0)"
```

Then invoke `superpowers:finishing-a-development-branch` to complete the work.

---

## Self-Review

**Spec coverage:**
- §1 Factor semantics → Task 1 (`hasTotp`, either-factor `hasTwoFactorEnabled`, `securityOnboarded` = `!must_change_password`, `resetIfNoFactor`). Callers using `securityOnboarded()`/`hasTwoFactorEnabled()` (`routes/channels.php`, Login) need no edit — they call the methods whose behaviour changed; Login routing to the challenge for a key-only user is correct (Task 5 makes the challenge usable). ✓
- §2 Onboarding not forced → Task 2 (middleware + PasswordChange → dashboard; broadcast gate). ✓
- §3 Recovery codes modal → Task 3 (new modal + view, delete page/view/route, keep download) + Task 4 (generate on first factor, open modal) + Task 6 (Settings "verwalten" → modal). ✓
- §4 Challenge adapts → Task 5 (verify guarded, field gated, key-only backup code). ✓
- §5 Settings manage factors → Task 6 (TOTP removable always + reset, keys gated on `available()` only + last-key reset, "empfohlen" hint, disable copy reframed). ✓
- §6 forgot-password → Task 7 (key-only safe, no-2FA note). ✓
- Files-touched + Testing sections → covered across Tasks 1-8; R12/Codex in Task 8. ✓

**Placeholder scan:** No "TBD"/"handle edge cases"; the one structural Blade edit (challenge field) gives the concrete replacement markup. The "existing 2FA tests" updates in Task 8 are inherently read-then-fix but bounded with explicit guidance per file. ✓

**Type consistency:** Method names consistent across tasks — `hasTotp()`, `hasTwoFactorEnabled()`, `securityOnboarded()`, `resetIfNoFactor()`, `hasRecoveryCodes()`, `replaceRecoveryCodes()`, `recoveryCodes()`. Modal component `App\Livewire\Modals\RecoveryCodes` ↔ dispatch name `modals.recovery-codes` ↔ view `livewire.modals.recovery-codes`. Flash key `open_recovery_modal` set in Task 4 (TwoFactorSetup) and read in Task 4 (Settings). View locals `hasTotp`/`twoFactorEnabled` defined in Task 6 `render()` and used in the Task 6 blade. ✓
