# 2FA Challenge — Backup Code as its own button + view — 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:** Move the backup (recovery) code off the combined 2FA challenge screen into its own button + dedicated route/view, sharing all rate-limit and login logic via a trait.

**Architecture:** Extract the pending-user resolver, the brute-force buckets and the login success-tail from `TwoFactorChallenge` into a `CompletesTwoFactorChallenge` trait. A new `TwoFactorBackup` full-page Livewire component (guest route `/two-factor-challenge/backup`) reuses the trait and accepts only recovery codes. The main challenge view shows only the primary factor (TOTP field *or* security-key button) plus a subordinate "Backup-Code verwenden" link; a key-only user with no secure context (http + bare IP) is redirected straight to the backup view from `mount()`. IP-vs-domain factor routing (`WebauthnService::available()`) is unchanged.

**Tech Stack:** Laravel 13, Livewire v3 (class-based, full-page components as routes), Tailwind v4, PHPUnit + `livewire/livewire` testing, Pint. Spec: `docs/superpowers/specs/2026-06-20-2fa-challenge-backup-view-design.md`.

---

## Pre-flight

- Dev stack up: `docker compose up -d` (app on :80). **Run ALL tooling inside the container (R8).**
- Branch: `feat/v1-foundation` (already checked out).
- Known flake (memory: test view-cache race): if `php artisan test` shows Blade-compile races against the live dev stack, re-run the affected filter with an isolated compiled-view dir, e.g. `docker compose exec -e VIEW_COMPILED_PATH=/tmp/views-test app php artisan test --filter=...`.

## File structure

| File | Responsibility |
|---|---|
| `app/Livewire/Concerns/CompletesTwoFactorChallenge.php` | **New.** Shared trait: `pendingUser()`, `getPendingHasTotpProperty()`, `webauthnAvailable()`, rate-limit buckets/helpers, `completeLogin()`. |
| `app/Livewire/Auth/TwoFactorChallenge.php` | **Modify.** Use the trait, drop the extracted methods, add the mount→backup redirect. WebAuthn methods stay here. |
| `app/Livewire/Auth/TwoFactorBackup.php` | **New.** Backup-only component: session guard + recovery-code `verify()`. |
| `resources/views/livewire/auth/two-factor-challenge.blade.php` | **Modify.** Gate the form to TOTP users, remove the backup field/hints, add the backup button. |
| `resources/views/livewire/auth/two-factor-backup.blade.php` | **New.** Backup-code field + return links. |
| `routes/web.php` | **Modify.** New `two-factor.challenge.backup` route in the `guest` group. |
| `lang/de/auth.php`, `lang/en/auth.php` | **Modify.** Add 4 keys; later remove 2 orphaned hint keys. |
| `tests/Feature/TwoFactorBackupTest.php` | **New.** Backup component coverage. |
| `tests/Feature/ChallengeFactorAdaptTest.php` | **Modify.** Flip the key-only assertion; add main-view content tests. |

---

### Task 1: i18n keys

**Files:**
- Modify: `lang/de/auth.php`, `lang/en/auth.php`

- [ ] **Step 1: Add the 4 new German keys**

In `lang/de/auth.php`, inside the `// ── 2FA challenge ──` block (right after the `'code' => 'Code',` line, before `'back_to_login'`), add:

```php
    'challenge_use_backup' => 'Backup-Code verwenden',
    'backup_heading' => 'Backup-Codes',
    'backup_subtitle' => 'Gib einen Backup-Code ein, um fortzufahren.',
    'back_to_options' => 'Zurück zu Anmelde-Optionen',
```

- [ ] **Step 2: Add the same 4 keys in English**

In `lang/en/auth.php`, in the matching `// ── 2FA challenge ──` block after `'code' => 'Code',`:

```php
    'challenge_use_backup' => 'Use backup code',
    'backup_heading' => 'Backup codes',
    'backup_subtitle' => 'Enter a backup code to continue.',
    'back_to_options' => 'Back to sign-in options',
```

- [ ] **Step 3: Verify both files parse and the keys are present + identical**

Run:
```bash
docker compose exec app php -r "\$de=require 'lang/de/auth.php'; \$en=require 'lang/en/auth.php'; \$k=['challenge_use_backup','backup_heading','backup_subtitle','back_to_options']; foreach(\$k as \$x){ echo \$x.': '.(isset(\$de[\$x])&&isset(\$en[\$x])?'OK':'MISSING').PHP_EOL; }"
```
Expected: four `OK` lines.

- [ ] **Step 4: Commit**

```bash
git add lang/de/auth.php lang/en/auth.php
git commit -m "i18n(auth): add 2FA backup-view strings"
```

---

### Task 2: Extract the shared trait

Pure refactor — no behaviour change. The existing 2FA test suite is the safety net.

**Files:**
- Create: `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`

- [ ] **Step 1: Create the trait**

Create `app/Livewire/Concerns/CompletesTwoFactorChallenge.php`:

```php
<?php

namespace App\Livewire\Concerns;

use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;

/**
 * Shared 2FA-challenge plumbing for the TOTP/key view and the backup-only view: the
 * pending-user resolver, the brute-force buckets (ONE shared counter set across both
 * views), and the login success tail. Centralising it guarantees both views hit the
 * same rate limit and complete login identically.
 */
trait CompletesTwoFactorChallenge
{
    protected ?User $pendingUser = null;

    protected bool $pendingResolved = false;

    /** The pending 2FA user, resolved once per request from the session. */
    protected function pendingUser(): ?User
    {
        if (! $this->pendingResolved) {
            $this->pendingUser = User::find(session('2fa.user'));
            $this->pendingResolved = true;
        }

        return $this->pendingUser;
    }

    /** Whether the PENDING user has TOTP — the authenticator code field renders only then. */
    public function getPendingHasTotpProperty(): bool
    {
        return (bool) $this->pendingUser()?->hasTotp();
    }

    /** Whether to offer the security-key option for the pending user (domain+HTTPS + has a key). */
    public function webauthnAvailable(): bool
    {
        $user = $this->pendingUser();

        return $user !== null
            && app(WebauthnService::class)->available()
            && $user->hasWebauthnCredentials();
    }

    /**
     * Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id
     * (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user
     * backstop, so a distributed (multi-IP) brute-force of the code is still capped. Both
     * decay in minutes (never a permanent lockout; clusev:reset-admin stays open).
     *
     * @return array<string, array{0:int,1:int}> key => [maxAttempts, decaySeconds]
     */
    protected function rateLimitBuckets(): array
    {
        $uid = (string) session('2fa.user');

        return [
            'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60],
            'two-factor-acct:'.md5($uid) => [20, 900],
        ];
    }

    protected function assertNotRateLimited(): void
    {
        foreach ($this->rateLimitBuckets() as $key => [$max]) {
            if (RateLimiter::tooManyAttempts($key, $max)) {
                throw ValidationException::withMessages([
                    'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]),
                ]);
            }
        }
    }

    protected function hitRateLimit(): void
    {
        foreach ($this->rateLimitBuckets() as $key => [, $decay]) {
            RateLimiter::hit($key, $decay);
        }
    }

    protected function clearRateLimit(): void
    {
        foreach (array_keys($this->rateLimitBuckets()) as $key) {
            RateLimiter::clear($key);
        }
    }

    /** Log the verified pending user in and finish the challenge (shared success tail). */
    protected function completeLogin(User $user)
    {
        $remember = (bool) session('2fa.remember');
        session()->forget(['2fa.user', '2fa.remember']);

        Auth::login($user, $remember);
        session()->regenerate();

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

- [ ] **Step 2: Rewrite `TwoFactorChallenge` to use the trait**

Replace the entire contents of `app/Livewire/Auth/TwoFactorChallenge.php` with:

```php
<?php

namespace App\Livewire\Auth;

use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;

#[Layout('layouts.auth')]
class TwoFactorChallenge extends Component
{
    use CompletesTwoFactorChallenge;

    #[Validate('required|string')]
    public string $code = '';

    public function mount()
    {
        if (! session()->has('2fa.user')) {
            return $this->redirect(route('login'), navigate: true);
        }
    }

    public function verify()
    {
        $this->validate();

        $this->assertNotRateLimited();

        $user = User::find(session('2fa.user'));

        if (! $user || ! $user->hasTwoFactorEnabled()) {
            session()->forget(['2fa.user', '2fa.remember']);
            throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
        }

        // 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->verifyTotp($this->code) || $user->useRecoveryCode($this->code);

        if (! $valid) {
            $this->hitRateLimit();
            throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
        }

        $this->clearRateLimit();

        return $this->completeLogin($user);
    }

    /** JSON request options for navigator.credentials.get — the JS posts the result to verifyWebauthn. */
    public function assertionOptions(WebauthnService $webauthn): array
    {
        $user = User::find(session('2fa.user'));
        abort_unless($user && $webauthn->available() && $user->hasWebauthnCredentials(), 404);

        return $webauthn->assertionOptions($user);
    }

    /** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */
    public function verifyWebauthn(array $assertion, WebauthnService $webauthn)
    {
        $this->assertNotRateLimited();

        $user = User::find(session('2fa.user'));

        if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) {
            $this->hitRateLimit();
            throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]);
        }

        $this->clearRateLimit();

        return $this->completeLogin($user);
    }

    public function render()
    {
        return view('livewire.auth.two-factor-challenge')->title(__('auth.title_challenge'));
    }
}
```

- [ ] **Step 3: Run the existing 2FA suite — must stay green**

Run:
```bash
docker compose exec app php artisan test --filter='TwoFactorChallengeRecoveryTest|TwoFactorWebauthnTest|ChallengeFactorAdaptTest|BruteForceHardeningTest'
```
Expected: PASS (behaviour unchanged — the trait just relocates the same code).

- [ ] **Step 4: Pint + commit**

```bash
docker compose exec app ./vendor/bin/pint app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
git add app/Livewire/Concerns/CompletesTwoFactorChallenge.php app/Livewire/Auth/TwoFactorChallenge.php
git commit -m "refactor(auth): extract CompletesTwoFactorChallenge trait"
```

---

### Task 3: Backup route + component + view (TDD)

**Files:**
- Create: `tests/Feature/TwoFactorBackupTest.php`
- Modify: `routes/web.php`
- Create: `app/Livewire/Auth/TwoFactorBackup.php`
- Create: `resources/views/livewire/auth/two-factor-backup.blade.php`

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

Create `tests/Feature/TwoFactorBackupTest.php`:

```php
<?php

namespace Tests\Feature;

use App\Livewire\Auth\TwoFactorBackup;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;

class TwoFactorBackupTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        Cache::flush(); // start every test with empty rate-limit buckets
    }

    private function enrolledUser(): User
    {
        return User::factory()->create([
            'two_factor_secret' => (new Google2FA)->generateSecretKey(),
            'two_factor_confirmed_at' => now(),
        ]);
    }

    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_backup_code_logs_in_and_is_consumed(): void
    {
        $user = $this->enrolledUser();
        $code = $user->replaceRecoveryCodes()[0];
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->set('code', $code)
            ->call('verify')
            ->assertRedirect(route('dashboard'));

        $this->assertAuthenticatedAs($user);
        $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
    }

    public function test_invalid_backup_code_fails(): void
    {
        $user = $this->enrolledUser();
        $user->replaceRecoveryCodes();
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->set('code', 'nope-nope')
            ->call('verify')
            ->assertHasErrors('code');

        $this->assertGuest();
    }

    public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void
    {
        $user = $this->enrolledUser();
        $user->replaceRecoveryCodes();
        $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->set('code', $totp)
            ->call('verify')
            ->assertHasErrors('code');

        $this->assertGuest();
    }

    public function test_requires_a_pending_2fa_session(): void
    {
        Livewire::test(TwoFactorBackup::class)
            ->assertRedirect(route('login'));
    }

    public function test_field_and_login_link_render(): void
    {
        $user = $this->enrolledUser();
        $user->replaceRecoveryCodes();
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->assertSee(__('auth.backup_heading'))
            ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present
            ->assertSee(__('auth.back_to_login'));
    }

    public function test_back_to_options_shown_for_a_totp_user(): void
    {
        $user = $this->enrolledUser();
        $user->replaceRecoveryCodes();
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->assertSee(__('auth.back_to_options'));
    }

    public function test_back_to_options_hidden_for_key_only_without_secure_context(): void
    {
        // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the
        // only path, so the "back to options" link would loop straight back here and is hidden.
        $user = $this->keyOnlyUser();
        $user->replaceRecoveryCodes();
        session()->put('2fa.user', $user->id);

        Livewire::test(TwoFactorBackup::class)
            ->assertDontSee(__('auth.back_to_options'))
            ->assertSee(__('auth.back_to_login'));
    }

    public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void
    {
        $user = $this->enrolledUser();
        $codes = $user->replaceRecoveryCodes();
        session()->put('2fa.user', $user->id);

        // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s).
        for ($i = 0; $i < 5; $i++) {
            Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify');
        }

        // A genuinely valid, unused backup code is now refused on the MAIN challenge by the
        // SAME shared bucket — proving one counter spans both components.
        Livewire::test(TwoFactorChallenge::class)
            ->set('code', $codes[0])
            ->call('verify')
            ->assertHasErrors('code');

        $this->assertGuest();
    }
}
```

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

Run:
```bash
docker compose exec app php artisan test --filter=TwoFactorBackupTest
```
Expected: FAIL — `Class "App\Livewire\Auth\TwoFactorBackup" not found` (and the route does not exist yet).

- [ ] **Step 3: Add the route**

In `routes/web.php`, in the `guest` middleware group, immediately after the existing `two-factor.challenge` line, add the backup sibling:

```php
        Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
        Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');
```

- [ ] **Step 4: Create the `TwoFactorBackup` component**

Create `app/Livewire/Auth/TwoFactorBackup.php`:

```php
<?php

namespace App\Livewire\Auth;

use App\Livewire\Concerns\CompletesTwoFactorChallenge;
use App\Models\User;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;

#[Layout('layouts.auth')]
class TwoFactorBackup extends Component
{
    use CompletesTwoFactorChallenge;

    #[Validate('required|string')]
    public string $code = '';

    public function mount()
    {
        if (! session()->has('2fa.user')) {
            return $this->redirect(route('login'), navigate: true);
        }
    }

    public function verify()
    {
        $this->validate();

        $this->assertNotRateLimited();

        $user = User::find(session('2fa.user'));

        if (! $user || ! $user->hasTwoFactorEnabled()) {
            session()->forget(['2fa.user', '2fa.remember']);
            throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
        }

        // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
        if (! $user->useRecoveryCode($this->code)) {
            $this->hitRateLimit();
            throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
        }

        $this->clearRateLimit();

        return $this->completeLogin($user);
    }

    public function render()
    {
        return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge'));
    }
}
```

- [ ] **Step 5: Create the backup view**

Create `resources/views/livewire/auth/two-factor-backup.blade.php`:

```blade
@php
    $fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
    $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
    $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp

<div class="space-y-7">
    <div class="space-y-1.5">
        <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
        <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.backup_heading') }}</h1>
        <p class="font-mono text-xs text-ink-3">{{ __('auth.backup_subtitle') }}</p>
    </div>

    <form wire:submit="verify" class="space-y-4">
        <div>
            <label for="code" class="{{ $label }}">{{ __('auth.challenge_backup_label') }}</label>
            <input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
                   class="{{ $fieldText }}" placeholder="{{ __('auth.challenge_backup_placeholder') }}" />
            @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
        </div>

        <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
            <svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
            <span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span>
            <span wire:loading wire:target="verify">{{ __('auth.checking') }}</span>
        </x-btn>
    </form>

    <div class="flex flex-col items-center gap-2.5">
        @if ($this->pendingHasTotp || $this->webauthnAvailable())
            <a href="{{ route('two-factor.challenge') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
                <x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_options') }}
            </a>
        @endif
        <a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
            <x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}
        </a>
    </div>
</div>
```

- [ ] **Step 6: Run the test — must pass**

Run:
```bash
docker compose exec app php artisan test --filter=TwoFactorBackupTest
```
Expected: PASS (all 8 tests).

- [ ] **Step 7: Pint + commit**

```bash
docker compose exec app ./vendor/bin/pint routes/web.php app/Livewire/Auth/TwoFactorBackup.php tests/Feature/TwoFactorBackupTest.php
git add routes/web.php app/Livewire/Auth/TwoFactorBackup.php resources/views/livewire/auth/two-factor-backup.blade.php tests/Feature/TwoFactorBackupTest.php
git commit -m "feat(auth): dedicated backup-code 2FA view + route"
```

---

### Task 4: Main view redesign + mount redirect (TDD)

The backup route now exists, so `TwoFactorChallenge::mount()` can safely redirect to it.

**Files:**
- Modify: `tests/Feature/ChallengeFactorAdaptTest.php`
- Modify: `app/Livewire/Auth/TwoFactorChallenge.php`
- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php`

- [ ] **Step 1: Update `ChallengeFactorAdaptTest` to the new behaviour (failing)**

Replace the entire contents of `tests/Feature/ChallengeFactorAdaptTest.php` with:

```php
<?php

namespace Tests\Feature;

use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
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_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);
    }

    public function test_totp_user_sees_the_backup_link_but_not_the_backup_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)
            ->assertSee('000000')                                  // TOTP field present
            ->assertSee(__('auth.challenge_use_backup'))           // backup link present
            ->assertDontSee(__('auth.challenge_backup_placeholder')); // no backup field here
    }

    public function test_key_only_user_without_secure_context_is_redirected_to_the_backup_view(): void
    {
        // No domain configured → webauthnAvailable() false, pendingHasTotp false: the backup code
        // is the only usable path, so mount() sends the user straight to the dedicated view.
        $user = $this->keyOnlyUser();
        session(['2fa.user' => $user->id, '2fa.remember' => false]);

        Livewire::test(TwoFactorChallenge::class)
            ->assertRedirect(route('two-factor.challenge.backup'));
    }

    public function test_key_only_user_with_secure_context_stays_and_shows_key_plus_backup(): void
    {
        $svc = $this->mock(WebauthnService::class);
        $svc->shouldReceive('available')->andReturn(true);

        $user = $this->keyOnlyUser();
        session(['2fa.user' => $user->id, '2fa.remember' => false]);

        Livewire::test(TwoFactorChallenge::class)
            ->assertNoRedirect()
            ->assertSee(__('auth.webauthn_login'))                 // security-key button
            ->assertSee(__('auth.challenge_use_backup'))           // backup link
            ->assertDontSee('000000')                              // no TOTP field
            ->assertDontSee(__('auth.challenge_backup_placeholder')); // no inline backup field
    }
}
```

- [ ] **Step 2: Run it — confirm the new assertions fail**

Run:
```bash
docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest
```
Expected: FAIL — the redirect test fails (no redirect yet) and the "no backup field" assertions fail (the field is still rendered).

- [ ] **Step 3: Add the mount redirect to `TwoFactorChallenge`**

In `app/Livewire/Auth/TwoFactorChallenge.php`, replace the `mount()` method with:

```php
    public function mount()
    {
        if (! session()->has('2fa.user')) {
            return $this->redirect(route('login'), navigate: true);
        }

        // No TOTP and no usable security key (a key-only user over http + bare IP, where WebAuthn
        // has no secure context) → the backup code is the only path: go straight to its own view.
        if (! $this->pendingHasTotp && ! $this->webauthnAvailable()) {
            return $this->redirect(route('two-factor.challenge.backup'), navigate: true);
        }
    }
```

- [ ] **Step 4: Redesign the main challenge view**

Replace the entire contents of `resources/views/livewire/auth/two-factor-challenge.blade.php` with:

```blade
@php
    $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
    $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
    $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp

<div class="space-y-7">
    <div class="space-y-1.5">
        <p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">{{ __('auth.two_factor') }}</p>
        <h1 class="font-display text-2xl font-bold tracking-tight text-ink">{{ __('auth.challenge_heading') }}</h1>
        <p class="font-mono text-xs text-ink-3">{{ $this->pendingHasTotp ? __('auth.challenge_subtitle') : __('auth.challenge_key_subtitle') }}</p>
    </div>

    @if (! $this->pendingHasTotp && $this->webauthnAvailable())
        {{-- Key-only: the security key is the primary path. --}}
        <x-btn variant="primary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
            <x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
        </x-btn>
    @endif

    @if ($this->pendingHasTotp)
        <form wire:submit="verify" class="space-y-4">
            <div>
                <label for="code" class="{{ $label }}">{{ __('auth.code') }}</label>
                <input wire:model="code" id="code" inputmode="text" autocomplete="one-time-code" autofocus
                       class="{{ $field }}" placeholder="000000" />
                @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
            </div>

            <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
                <svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
                <span wire:loading.remove wire:target="verify">{{ __('common.confirm') }}</span>
                <span wire:loading wire:target="verify">{{ __('auth.checking') }}</span>
            </x-btn>
        </form>

        @if ($this->webauthnAvailable())
            {{-- TOTP + key: the security key is the alternate path. --}}
            <div class="flex items-center gap-3">
                <span class="h-px flex-1 bg-line"></span>
                <span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span>
                <span class="h-px flex-1 bg-line"></span>
            </div>
            <x-btn variant="secondary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login($wire)">
                <x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
            </x-btn>
        @endif
    @endif

    {{-- Backup code lives on its own view — a deliberate, subordinate step. --}}
    <x-btn variant="secondary" size="lg" class="w-full" :href="route('two-factor.challenge.backup')" wire:navigate>
        {{ __('auth.challenge_use_backup') }}
    </x-btn>

    <div class="flex justify-center">
        <a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
            <x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('auth.back_to_login') }}
        </a>
    </div>
</div>
```

(Compared to the old view: `$fieldText` removed from the `@php` block; the always-on backup field, the `challenge_recovery_hint` and the `challenge_backup_only_hint` paragraphs are gone; the form is wrapped in `@if ($this->pendingHasTotp)`; the "Backup-Code verwenden" button is added before the back-to-login link.)

- [ ] **Step 5: Run the adapt test — must pass**

Run:
```bash
docker compose exec app php artisan test --filter=ChallengeFactorAdaptTest
```
Expected: PASS (4 tests).

- [ ] **Step 6: Run the whole 2FA + auth suite — guard against regressions**

Run:
```bash
docker compose exec app php artisan test --filter='TwoFactor|Challenge|Webauthn|BruteForce|Login|Factor'
```
Expected: PASS. (If `BruteForceHardeningTest` or another suite asserted the old combined-view backup field, fix that assertion to key on `challenge_backup_placeholder` per the substring caveat below, then re-run.)

> **Substring caveat:** never assert `assertSee/DontSee(__('auth.challenge_backup_label'))` to detect the backup field — `challenge_backup_label` ("Backup-Code") is a substring of `challenge_use_backup` ("Backup-Code verwenden"). Use `__('auth.challenge_backup_placeholder')` (`xxxxxxxxxx-xxxxxxxxxx`), which is unique to the field.

- [ ] **Step 7: Pint + commit**

```bash
docker compose exec app ./vendor/bin/pint app/Livewire/Auth/TwoFactorChallenge.php tests/Feature/ChallengeFactorAdaptTest.php
git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php tests/Feature/ChallengeFactorAdaptTest.php
git commit -m "feat(auth): split backup code out of the main 2FA challenge view"
```

---

### Task 5: Remove the orphaned hint keys

`challenge_recovery_hint` and `challenge_backup_only_hint` are no longer rendered by any view.

**Files:**
- Modify: `lang/de/auth.php`, `lang/en/auth.php`

- [ ] **Step 1: Confirm they are unreferenced**

Run:
```bash
docker compose exec app grep -rn "challenge_recovery_hint\|challenge_backup_only_hint" resources/ app/ || echo "NO REFERENCES"
```
Expected: `NO REFERENCES`. (If any line prints, stop — that view still needs the key; do not delete it.)

- [ ] **Step 2: Delete the two keys from both language files**

Remove these lines from `lang/de/auth.php`:
```php
    'challenge_recovery_hint' => 'Authenticator verloren? Gib einen deiner Backup-Codes ein.',
    'challenge_backup_only_hint' => 'Kein Authenticator nötig — Security-Key oder ein Backup-Code.',
```
And from `lang/en/auth.php`:
```php
    'challenge_recovery_hint' => 'Lost your authenticator? Enter one of your backup codes.',
    'challenge_backup_only_hint' => 'No authenticator needed — use a security key or a backup code.',
```

- [ ] **Step 3: Verify both files still parse**

Run:
```bash
docker compose exec app php -r "require 'lang/de/auth.php'; require 'lang/en/auth.php'; echo 'OK'.PHP_EOL;"
```
Expected: `OK`.

- [ ] **Step 4: Commit**

```bash
git add lang/de/auth.php lang/en/auth.php
git commit -m "i18n(auth): drop orphaned 2FA recovery hint strings"
```

---

### Task 6: Full verification (R12 browser + R15 Codex)

**Files:** none (verification only).

- [ ] **Step 1: Full test suite green**

Run:
```bash
docker compose exec app php artisan test
```
Expected: PASS, 0 failures.

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

Run:
```bash
docker compose exec app npm run build
```
Expected: build completes, no errors.

- [ ] **Step 3: R12 browser verify — `/two-factor-challenge` (DE + EN)**

With a pending-2FA session for a TOTP user, load `/two-factor-challenge` headless. Confirm:
- HTTP 200, zero console/network errors.
- Rendered DOM shows the TOTP field + the "Backup-Code verwenden" button + "Zurück zur Anmeldung"; **no** inline backup field (`xxxxxxxxxx-xxxxxxxxxx` placeholder absent).
- No leaked `@`/`{{ }}`/`$var`/`group.key` text (R17).
- 3 breakpoints (375 / 768 / 1280), touch targets ≥44px.

(Bare-IP setup: a key-only pending session should 302/redirect to `/two-factor-challenge/backup` — verify the redirect lands.)

- [ ] **Step 4: R12 browser verify — `/two-factor-challenge/backup` (DE + EN)**

Load `/two-factor-challenge/backup` with a pending-2FA session. Confirm HTTP 200, zero console/network errors, the backup field + "Bestätigen" + the correct return links (for a TOTP user both "Zurück zu Anmelde-Optionen" and "Zurück zur Anmeldung"), no leaked template tokens, 3 breakpoints.

- [ ] **Step 5: R15 — Codex review clean**

Run `/codex:review` over the branch diff. Fix anything it flags (errors or security) and re-run until it reports no errors and no security issues.

- [ ] **Step 6: Final Pint**

Run:
```bash
docker compose exec app ./vendor/bin/pint --dirty
```
Expected: no changes / all clean. Commit if Pint reformatted anything.

---

## Self-review notes (already reconciled with the spec)

- **Shared rate-limit bucket** — Task 2 keeps the exact bucket key format; Task 3's cross-component test proves the counter spans both views.
- **No property→method churn** — the computed property `getPendingHasTotpProperty()` moves into the trait, so every `$this->pendingHasTotp` blade/test usage is unchanged.
- **Redirect-loop guard** — `back_to_options` is hidden exactly when `! pendingHasTotp && ! webauthnAvailable()` (covered by `TwoFactorBackupTest::test_back_to_options_hidden_for_key_only_without_secure_context`).
- **Substring trap** — backup-field assertions use `challenge_backup_placeholder`, never `challenge_backup_label`.
- **Out of scope (Spec 2):** auth-failure → fail2ban hard IP ban. Not in this plan.
