feat(auth): show two-factor code as separate digit boxes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-29 18:17:18 +01:00
co-authored by Claude Opus 5.5
parent 96ed09f0cc
commit 607daf80be
2 changed files with 42 additions and 5 deletions
@@ -3,6 +3,8 @@
<div class="flex flex-col gap-4" x-data="{
showRecovery: false,
submitting: false,
code: '',
codeFocused: false,
handleSubmit(event) {
if (this.submitting) {
event.preventDefault();
@@ -12,6 +14,7 @@
},
submitAuthenticatorCode(event) {
event.target.value = event.target.value.replace(/\D/g, '').slice(0, 6);
this.code = event.target.value;
if (event.target.value.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
@@ -43,11 +46,23 @@
@csrf
<div x-show="!showRecovery" class="flex flex-col gap-3">
<input x-ref="authenticatorCode" type="text" name="code" inputmode="numeric"
pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus
aria-label="Two-factor authentication code" :disabled="showRecovery"
@input="submitAuthenticatorCode($event)"
class="mx-auto h-14 w-64 rounded-md border border-neutral-300 bg-white px-4 text-center text-xl font-semibold tracking-[0.5em] text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white" />
<div class="relative mx-auto flex gap-2">
@for ($i = 0; $i < 6; $i++)
<div aria-hidden="true" data-code-digit
class="flex h-14 w-11 items-center justify-center rounded-md border bg-white text-xl font-semibold text-neutral-900 transition-colors dark:bg-coolgray-100 dark:text-white"
:class="codeFocused && (code.length === {{ $i }} || (code.length === 6 && {{ $i }} === 5))
? 'border-coollabs ring-1 ring-coollabs'
: 'border-neutral-300 dark:border-white/10'"
x-text="code[{{ $i }}] ?? ''"></div>
@endfor
<input x-ref="authenticatorCode" type="text" name="code" inputmode="numeric"
pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus
aria-label="Two-factor authentication code" :disabled="showRecovery"
x-init="codeFocused = document.activeElement === $el"
@input="submitAuthenticatorCode($event)" @focus="codeFocused = true"
@blur="codeFocused = false"
class="absolute inset-0 h-full w-full cursor-text rounded-md border-0 bg-transparent text-transparent caret-transparent opacity-0 focus:outline-none focus:ring-0" />
</div>
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())">
Use a recovery code
+22
View File
@@ -110,6 +110,28 @@ it('submits the automatic two factor challenge only once', function () {
->screenshot(filename: 'login-two-factor-enter-single-submission');
});
it('shows the two factor code as separate digits', function () {
config(['app.maintenance.driver' => 'file']);
$user = createRootUser();
$user->forceFill([
'two_factor_secret' => Fortify::currentEncrypter()->encrypt('JBSWY3DPEHPK3PXP'),
'two_factor_confirmed_at' => now(),
])->save();
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertPathIs('/two-factor-challenge')
->screenshot(filename: 'login-two-factor-digits-empty');
$page->type('code', '123')
->assertScript("[...document.querySelectorAll('[data-code-digit]')].map((box) => box.textContent).join(',')", '1,2,3,,,')
->assertNoJavaScriptErrors()
->screenshot(filename: 'login-two-factor-digits-partial');
});
/**
* Create the root user (id 0) with known credentials for browser login tests.
*/