clusev/docs/superpowers/plans/2026-06-14-account-recovery.md

41 KiB

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

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

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:

    /** @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
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

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:

        $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):

        $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:

<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.challenge_recovery_hint') }}</p>
  • Step 6: Pint + commit
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

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

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
<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):

        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):

<x-btn href="{{ route('two-factor.recovery') }}" variant="secondary">{{ __('auth.recovery_manage') }}</x-btn>

lang/de/auth.php add:

    '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:

    '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).

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

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

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:

@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:

    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:

<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).

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"

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

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
    /** 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

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:

@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).

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

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

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
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.