diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php
index 9764bf0..1557b97 100644
--- a/app/Livewire/Auth/TwoFactorChallenge.php
+++ b/app/Livewire/Auth/TwoFactorChallenge.php
@@ -23,6 +23,12 @@ class TwoFactorChallenge extends Component
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);
+ }
}
public function verify()
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php
index c474f60..0ffcace 100644
--- a/resources/views/livewire/auth/two-factor-challenge.blade.php
+++ b/resources/views/livewire/auth/two-factor-challenge.blade.php
@@ -1,6 +1,5 @@
@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';
- $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
@@ -17,41 +16,42 @@
{{ __('auth.webauthn_login') }}
-
-
- {{ __('common.or') }}
-
-
@endif
-
+
+
+ {{ __('common.confirm') }}
+ {{ __('auth.checking') }}
+
+
- @if ($this->pendingHasTotp && $this->webauthnAvailable())
-
-
- {{ __('common.or') }}
-
-
-
- {{ __('auth.webauthn_login') }}
-
+ @if ($this->webauthnAvailable())
+ {{-- TOTP + key: the security key is the alternate path. --}}
+
+
+ {{ __('common.or') }}
+
+
+
+ {{ __('auth.webauthn_login') }}
+
+ @endif
@endif
+ {{-- Backup code lives on its own view — a deliberate, subordinate step. --}}
+
+ {{ __('auth.challenge_use_backup') }}
+
+
{{ __('auth.back_to_login') }}
diff --git a/tests/Feature/ChallengeFactorAdaptTest.php b/tests/Feature/ChallengeFactorAdaptTest.php
index 8e75dd6..f96d3b3 100644
--- a/tests/Feature/ChallengeFactorAdaptTest.php
+++ b/tests/Feature/ChallengeFactorAdaptTest.php
@@ -5,6 +5,7 @@ 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;
@@ -21,23 +22,6 @@ class ChallengeFactorAdaptTest extends TestCase
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)
- ->assertDontSee('000000')
- ->assertSee(__('auth.challenge_backup_label'))
- ->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()]);
@@ -45,4 +29,42 @@ class ChallengeFactorAdaptTest extends TestCase
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
+ }
}