# TwoFactorChallenge

> Second-factor step at sign-in: authenticator code or recovery code, trust this device, and a passkey alternative; en and ar built in.

Source: https://docs.nasaqui.com/components/two-factor-challenge

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/two-factor-challenge.json
```

The step after a correct password. It asks for the code from an authenticator app, and lets the person switch to a
recovery code or, when you provide `onPasskey`, to a passkey. "Trust this device" is a checkbox.

## When to use

- After `LoginForm` when the account has two-factor enabled.

## When not to use

- Emailed or texted codes: use `VerifyOtpForm`.
- Enrolling in 2FA: use a setup flow.

## Import

```tsx
import { TwoFactorChallenge } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { TwoFactorChallenge } from "@fadymondy/nasaq/web";

export function Challenge() {
  return (
    <TwoFactorChallenge
      onSubmit={async ({ code, method, trustDevice }) => {
        const ok = await api.second(code, method, trustDevice);
        if (!ok) return { error: "That code is not right, or it expired." };
      }}
    />
  );
}

declare const api: { second(c: string, m: string, t: boolean): Promise<boolean> };
```

## Anatomy

```
TwoFactorChallenge               data-slot="two-factor-challenge" (form, noValidate)
├─ description                   depends on method
├─ error summary                 role="alert"
├─ OtpInput (totp) | Input (recovery)
├─ trust device checkbox
├─ submit Button
├─ "Use a recovery code" / "Use authenticator code" toggle
├─ "Use a passkey instead"       (with onPasskey and WebAuthn)
└─ footer slot
```

## API

**TwoFactorChallenge**: every `form` prop except `onSubmit` and `children`, plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onSubmit` | `(values: TwoFactorValues) => Promise<AuthSubmitResult> \| AuthSubmitResult` | required | `TwoFactorValues` is `{ code, method, trustDevice }`. |
| `onPasskey` | `() => void \| Promise<unknown>` | | Shows the passkey alternative when supported. |
| `defaultMethod` | `"totp" \| "recovery"` | `"totp"` | Starting method. |
| `showTrustDevice` | `boolean` | `true` | Show the checkbox. |
| `length` | `number` | `6` | Authenticator code length. |
| `footer` | `ReactNode` | | Slot under the actions. |
| `labels` | `Partial<TwoFactorChallengeLabels>` | English or Arabic | Every string. |

## Examples

**Start on a recovery code, no trust box**

```tsx
import { TwoFactorChallenge } from "@fadymondy/nasaq/web";

export function Recovery() {
  return <TwoFactorChallenge defaultMethod="recovery" showTrustDevice={false} onSubmit={async () => {}} />;
}
```

## Accessibility

- The form is `noValidate` and validates itself, so messages are yours to localise and read in one place. Every field
  has a visible `<label>`; an invalid field gets `aria-invalid` and its message is linked with `aria-describedby`.
- On a failed submit focus moves to the first invalid field. When the failure is for the whole form (`error`) the
  summary at the top of the form (`role="alert"`) is focused instead, so it is announced.
- The submit button shows a spinner and is `aria-busy` while `onSubmit` runs; a second submit is ignored.
- The authenticator boxes carry `autocomplete="one-time-code"`; the recovery field is a text input with
  `autocomplete="off"`, no autocapitalise and no spellcheck.
- Switching method moves focus to the new field and clears any error.

## RTL & i18n

- Built-in English and Arabic strings follow the Nasaq locale (`useNasaq`). Pass `labels` to change any of them or to
  add another language; keys you omit keep the built-in text.
- Layout uses logical properties, so it mirrors under `dir="rtl"` with no extra work.
- Codes are entered left to right in both directions.

## Styling & tokens

- Built from `OtpInput`, `Input`, `Checkbox`, `Button` and `Alert`; tokens only.

## Do / Don't

- Do offer the recovery-code route.
- Do rate-limit attempts on the server.
- Don't tell people which factor failed beyond "wrong or expired".
- Don't trust a device without the checkbox.

## Related

- [`login-form`](https://docs.nasaqui.com/components/login-form)
- [`verify-otp-form`](https://docs.nasaqui.com/components/verify-otp-form)
- [`otp-input`](https://docs.nasaqui.com/components/otp-input)
- [`auth-layout`](https://docs.nasaqui.com/components/auth-layout)

## Lab

https://docs.nasaqui.com/?path=/docs/components-auth-two-factor-challenge--docs

## Code

### React

```tsx
import { TwoFactorChallenge } from "@fadymondy/nasaq/web";

export function Challenge() {
  return (
    <TwoFactorChallenge
      onSubmit={async ({ code, method, trustDevice }) => {
        const ok = await api.second(code, method, trustDevice);
        if (!ok) return { error: "That code is not right, or it expired." };
      }}
    />
  );
}

declare const api: { second(c: string, m: string, t: boolean): Promise<boolean> };
```

### shadcn

```tsx
import { TwoFactorChallenge } from "@/components/ui/two-factor-challenge";

export function Challenge() {
  return (
    <TwoFactorChallenge
      onSubmit={async ({ code, method, trustDevice }) => {
        const ok = await api.second(code, method, trustDevice);
        if (!ok) return { error: "That code is not right, or it expired." };
      }}
    />
  );
}

declare const api: { second(c: string, m: string, t: boolean): Promise<boolean> };
```

### Vue

```vue
<script setup lang="ts">
import { NqTwoFactorChallenge, type TwoFactorValues } from "@fadymondy/nasaq/vue";

// Your API call. Resolve { error } for a wrong code (the input clears and refocuses).
async function verify({ code, method, trustDevice }: TwoFactorValues) {
  const res = await fetch("/api/second-factor", { method: "POST", body: JSON.stringify({ code, method, trustDevice }) });
  if (!res.ok) return { error: "That code is not right, or it expired." };
}
</script>

<template>
  <div class="w-80">
    <NqTwoFactorChallenge :on-submit="verify" :on-passkey="() => {}">
      <template #footer><a href="/login" class="underline underline-offset-2">Back to sign in</a></template>
    </NqTwoFactorChallenge>
  </div>
</template>
```

### Blade

```blade
{{-- Your API call. Resolve { error } for a wrong code (the input clears and refocuses); the demo accepts 123456. --}}
<div class="w-80">
    <x-nq::two-factor-challenge :passkey="true"
        x-on:nq-two-factor="$event.detail.waitUntil(new Promise((done) => setTimeout(() => done($event.detail.code === '123456' || $event.detail.code === 'abcd-1234' ? undefined : { error: 'That code is not right, or it expired.' }), 300)))">
        <x-slot:footer><a href="/login" class="underline underline-offset-2">Back to sign in</a></x-slot:footer>
    </x-nq::two-factor-challenge>
</div>
```

### HTML + Alpine

```html
<div class="w-80">
    <form data-slot="two-factor-challenge" novalidate x-data="nqTwoFactorChallenge(JSON.parse('{\u0022length\u0022:6,\u0022defaultMethod\u0022:\u0022totp\u0022,\u0022passkey\u0022:true,\u0022names\u0022:[\u0022code\u0022],\u0022failed\u0022:\u0022Something went wrong. Try again.\u0022,\u0022labels\u0022:{\u0022totpDescription\u0022:\u0022Open your authenticator app and enter the {length}-digit code.\u0022,\u0022recoveryDescription\u0022:\u0022Enter one of the recovery codes you saved when you turned on two-step verification. Each code works once.\u0022,\u0022totpGroup\u0022:\u0022Authenticator code\u0022,\u0022recoveryLabel\u0022:\u0022Recovery code\u0022,\u0022recoveryPlaceholder\u0022:\u0022xxxx-xxxx\u0022,\u0022trust\u0022:\u0022Trust this device for 30 days\u0022,\u0022submit\u0022:\u0022Verify\u0022,\u0022useRecovery\u0022:\u0022Use a recovery code instead\u0022,\u0022useTotp\u0022:\u0022Use your authenticator app instead\u0022,\u0022usePasskey\u0022:\u0022Use a passkey instead\u0022,\u0022incomplete\u0022:\u0022Enter all {length} digits.\u0022,\u0022recoveryRequired\u0022:\u0022Enter a recovery code.\u0022,\u0022failed\u0022:\u0022Something went wrong. Try again.\u0022}}'))" x-bind:data-method="method"
    data-method="totp" x-bind:aria-busy="pending ? 'true' : null" x-effect="inv = Boolean(message())" x-on:submit.prevent="onSubmit()"
     x-on:nq-two-factor="$event.detail.waitUntil(new Promise((done) =&gt; setTimeout(() =&gt; done($event.detail.code === &#039;123456&#039; || $event.detail.code === &#039;abcd-1234&#039; ? undefined : { error: &#039;That code is not right, or it expired.&#039; }), 300)))" class="flex w-full flex-col gap-4">
    <p class="text-body-sm text-muted-foreground" x-text="method === 'totp' ? 'Open your authenticator app and enter the 6-digit code.' : 'Enter one of the recovery codes you saved when you turned on two-step verification. Each code works once.'">Open your authenticator app and enter the 6-digit code.</p>
    <input type="hidden" name="method" x-bind:value="method" value="totp" />
    <template x-if="method === 'totp'">
        <div class="flex flex-col gap-2" x-effect="markInvalid($el)">
            <div role="group" dir="ltr" data-slot="otp-input" x-data="nqOtpInput('', 6, 'numeric')" x-modelable="value"
    aria-label="Authenticator code" aria-describedby="nq-2fa-000001-message" x-model="code" x-on:complete="onComplete($event.detail)" class="inline-flex items-center gap-2 self-center">
            <input data-slot="otp-input-box" x-bind="box(0)" type="text" value=""
                        aria-label="Digit 1 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
               autofocus             class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
            <input data-slot="otp-input-box" x-bind="box(1)" type="text" value=""
                        aria-label="Digit 2 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
                          class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
            <input data-slot="otp-input-box" x-bind="box(2)" type="text" value=""
                        aria-label="Digit 3 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
                          class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
            <input data-slot="otp-input-box" x-bind="box(3)" type="text" value=""
                        aria-label="Digit 4 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
                          class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
            <input data-slot="otp-input-box" x-bind="box(4)" type="text" value=""
                        aria-label="Digit 5 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
                          class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
            <input data-slot="otp-input-box" x-bind="box(5)" type="text" value=""
                        aria-label="Digit 6 of 6"
            inputmode="numeric" autocomplete="one-time-code" autocapitalize="off" spellcheck="false"
                          class="size-control min-h-[var(--nq-touch-min,0px)] min-w-0 rounded-control border border-input bg-card p-0 text-center text-body font-medium tabular-nums text-foreground transition-colors duration-150 ease-nq outline-none focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px]" />
        <input type="hidden" name="code" :value="value" value="" /></div>
            <p id="nq-2fa-000001-message" role="alert" class="text-center text-caption text-nq-danger-text" style="display: none" x-show="message()" x-text="message()"></p>
        </div>
    </template>
    <template x-if="method === 'recovery'">
        <div data-slot="field" x-data="nqField(false)" x-modelable="invalid" x-id="['nq-field']"
         data-valid     x-model="inv" class="flex flex-col gap-1.5">
    <label data-slot="field-label"  for="nq-2fa-000001-code" class="text-label text-foreground data-disabled:opacity-50">Recovery code</label>
            <input data-slot="input" type="text"
     dir="ltr"      name="code"             id="nq-2fa-000001-code" autofocus="autofocus" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="xxxx-xxxx" aria-describedby="nq-2fa-000001-code-error" x-model="code" x-bind:aria-invalid="inv ? &#039;true&#039; : null" class="w-full min-w-0 rounded-control border border-input bg-card px-3 text-body text-foreground min-h-[var(--nq-touch-min,0px)] transition-colors duration-150 ease-nq outline-none placeholder:text-muted-foreground focus-visible:border-nq-focus focus-visible:outline-1 focus-visible:outline-nq-focus data-invalid:border-nq-danger aria-invalid:border-nq-danger disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:text-[16px] h-control text-start font-mono">
            <div data-slot="field-error" role="alert" x-show="invalid"  style="display: none"  id="nq-2fa-000001-code-error" class="text-caption text-nq-danger-text"><span x-text="message()"></span></div>
</div>
    </template>
            <label class="flex items-center gap-2 text-body-sm text-foreground">
            <button type="button" role="checkbox" data-slot="checkbox" x-data="nqCheckbox(false, false)" x-modelable="checked" x-bind="root"
    aria-checked="false"
     data-unchecked                 x-model="trust" class="relative inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-nq-line-strong bg-card text-primary-foreground outline-none transition-colors duration-150 ease-nq data-checked:border-primary data-checked:bg-primary data-indeterminate:border-primary data-indeterminate:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus data-disabled:cursor-not-allowed data-disabled:opacity-50 after:absolute after:-inset-1">
    <span data-slot="checkbox-indicator" class="flex items-center justify-center [&_svg]:size-3 [&_svg]:stroke-3" x-show="checked || indeterminate"  style="display: none" >
        <svg aria-hidden="true" x-show="indeterminate" style="display: none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M5 12h14"/>
</svg>        <svg aria-hidden="true" x-show="! indeterminate" style="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg>    </span>
    <input type="hidden" name="trustDevice" value="1" x-bind:disabled="! checked"  disabled ></button>
            Trust this device for 30 days
        </label>
        <button data-slot="button"
     type="submit"                         x-bind:disabled="pending || passkeyPending" x-bind:data-disabled="pending || passkeyPending ? &#039;&#039; : null" x-bind:aria-busy="pending ? &#039;true&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] h-[calc(var(--nq-control)+8px)] px-5 text-body">
        <template x-if="pending"><svg data-slot="spinner" aria-hidden="true" class="size-4 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg></template>
        Verify</button>
    <div class="flex flex-col items-center gap-1">
        <button data-slot="button"
     type="button"                         x-on:click="switchMethod()" x-bind:disabled="pending" x-bind:data-disabled="pending ? &#039;&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current h-auto px-0">
        <span x-text="method === 'totp' ? 'Use a recovery code instead' : 'Use your authenticator app instead'">Use a recovery code instead</span></button>
                    <button data-slot="two-factor-passkey"
     type="button"                         x-show="passkeyOn" style="display: none" x-on:click="passkey()" x-bind:disabled="pending || passkeyPending" x-bind:data-disabled="pending || passkeyPending ? &#039;&#039; : null" x-bind:aria-busy="passkeyPending ? &#039;true&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground underline decoration-nq-line underline-offset-4 hover:decoration-current h-auto px-0">
        <template x-if="passkeyPending"><svg data-slot="spinner" aria-hidden="true" class="size-4 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg></template>
                <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/>
  <circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>
</svg>                Use a passkey instead</button>
            </div>
            <div class="text-center text-body-sm"><a href="/login" class="underline underline-offset-2">Back to sign in</a></div>
    </form>
</div>
```
