DGit UI › Blocks

Sign-in flow

Neotax's sign-in, monochrome: email, then a lookup picks the step (6-digit code, password, passkey, Google-only, create account, two-step, forgot password). Server calls are props; Korean or English strings.

Install

npx shadcn add @dgit/auth-flow

First time? Add the registry to components.json: see Get started.

Depends on @dgit/auth-page, lucide-react.

auth-flow.tsx

"use client";

import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { CircleAlert, LoaderCircle, MailOpen } from "lucide-react";
import { cn } from "@/lib/utils";
import { AuthButton, AuthCodeBoxes, AuthDivider, AuthField, AuthLayout } from "@/registry/dgit/blocks/auth-page";

/**
 * The whole sign-in flow on one screen, from Neotax's (auth.neotax.tech): type an email, press Continue, and
 * the app's `lookup` says whether the account exists and how it signs in. The screen then branches
 * (`stepAfterLookup`):
 *
 *   no account          → create account (name, password) → 6-digit code    (signup on)
 *                       → 6-digit code by email                              (signup off, code on)
 *   has a passkey       → passkey prompt → on cancel: password, else back to email
 *   has a password      → password · "Forgot password?" → reset mail → "Mail sent"
 *   email code          → 6-digit code by email                              (code on)
 *   Google-only account → one "Continue with Google" button
 *   2-step account      → authenticator code or a recovery code (when a sign-in answers `twoFactor: true`)
 *
 * The block holds no URLs and no app logic: every server call is a prop, and `onDone` decides where to go.
 * Strings are Korean by default; pass `text={AUTH_TEXT_EN}` (or any part of it) for English.
 *
 *   <AuthFlow
 *     brand={<Logo />}
 *     methods={{ code: true, google: true }}
 *     lookup={(email) => api.post("/login/lookup", { email })}
 *     sendCode={(email) => api.post("/login/code", { email })}
 *     verifyCode={({ email, code }) => api.post("/login/code/verify", { email, code })}
 *     google={() => auth.signIn.social({ provider: "google" })}
 *     onDone={() => location.replace(next)}
 *     text={{ emailTitle: "보더에 로그인하기" }}
 *   />
 */

// ── Types

/** Which ways in the app offers. Off by default; the screen only shows what's on. */
export interface AuthMethods {
  /** 6-digit code by email (passwordless). */
  code?: boolean;
  password?: boolean;
  passkey?: boolean;
  google?: boolean;
  /** New accounts make a name (and a password, when `password` is on) and confirm a code. */
  signup?: boolean;
  /** Authenticator-app step after a sign-in that answers `twoFactor: true`. */
  twoFactor?: boolean;
}

/** What the account has, from `lookup`. `code: false` keeps an account off email codes (e.g. Google-only). */
export type LookupAnswer =
  | { ok?: true; account: "none" }
  | { ok?: true; account: "exists"; methods: { password?: boolean; passkey?: boolean; google?: boolean; code?: boolean } };

export type AuthErrorCode =
  | "generic"
  | "email_empty"
  | "email_invalid"
  | "email_hangul"
  | "no_account"
  | "invite_required"
  | "rate_limited"
  | "password_empty"
  | "password_invalid"
  | "password_too_short"
  | "password_too_long"
  | "name_required"
  | "name_too_long"
  | "name_invalid_chars"
  | "account_exists"
  | "invitation_mismatch"
  | "code_incomplete"
  | "code_invalid"
  | "code_expired"
  | "too_many_attempts"
  | "recently_sent"
  | "retry_after"
  | "too_many_codes"
  | "send_failed"
  | "no_pending_signup"
  | "google_failed"
  | "google_two_factor"
  | "passkey_failed"
  | "passkey_failed_password"
  | "two_factor_invalid"
  | "recovery_empty"
  | "recovery_invalid"
  | "session_expired"
  | "sign_in_failed";

/** A refused call. `error` picks the line from `text.errors` (unknown codes show the generic line); `message` replaces it. */
export interface AuthFailure {
  ok: false;
  error?: AuthErrorCode | (string & {});
  message?: string;
  /** For `recently_sent`: "Try again in N seconds." */
  retryAfterSeconds?: number;
}
/** A sign-in call's answer. `twoFactor: true` = the first factor passed and the authenticator step comes next. */
export type AuthResult = { ok: true; twoFactor?: boolean } | AuthFailure;

export type CodePurpose = "sign-in" | "sign-up";
export type AuthStep = "email" | "code" | "password" | "passkey" | "google" | "signup" | "twofactor" | "forgot" | "sent" | "routing";

export interface AuthFlowProps {
  methods: AuthMethods;
  /** Does the account exist, and how does it sign in. Without it: code if on, else password. */
  lookup?: (email: string) => Promise<LookupAnswer | AuthFailure>;
  /** Mail a 6-digit code (sign-in, or a new account's confirmation and its resend). */
  sendCode?: (email: string, purpose: CodePurpose) => Promise<AuthResult>;
  /** Check the code. For `sign-up` the new account's password comes along (Neotax signs in with it). */
  verifyCode?: (input: { email: string; code: string; purpose: CodePurpose; password?: string }) => Promise<AuthResult>;
  password?: (input: { email: string; password: string }) => Promise<AuthResult>;
  /** The browser's passkey prompt. `autoFill: true` is the email field's passkey autofill, started on load. */
  passkey?: (input: { email: string | null; autoFill: boolean }) => Promise<AuthResult>;
  /** Start Google sign-in. Usually it leaves the page (resolve nothing); a refusal shows the Google error. */
  google?: () => Promise<AuthResult | void>;
  /** Create the account. `next: "verify"` (default) goes to the code step; `"signed_in"` is done. */
  signup?: (input: { email: string; name: string; password?: string }) => Promise<{ ok: true; next?: "verify" | "signed_in" } | AuthFailure>;
  twoFactor?: (input: { code: string; recovery: boolean }) => Promise<AuthResult>;
  /** Mail a password reset link. */
  resetPassword?: (email: string) => Promise<AuthResult>;
  /** Signed in: go where the app goes next. The screen shows a spinner until it resolves; a throw shows `sign_in_failed`. */
  onDone: () => void | Promise<void>;

  /** Prefill (an invitation's email, say). */
  initialEmail?: string;
  /** Show an error from the start, e.g. `google_failed` when Google sent the person back with an error. */
  initialError?: AuthErrorCode;
  /** Show only the spinner (back from Google, the app is deciding where to go). */
  finishing?: boolean;
  passwordMin?: number;
  passwordMax?: number;
  /** Strings: Korean by default. Pass `AUTH_TEXT_EN`, or only the lines to change. */
  text?: Partial<Omit<AuthText, "errors">> & { errors?: Partial<AuthText["errors"]> };

  /** `AuthLayout`'s brand (top left), `app` ("Signing in to …") and footer. */
  brand?: ReactNode;
  app?: string;
  footer?: ReactNode;
  /** Only the column, without `AuthLayout` (to put it in your own page). */
  bare?: boolean;
  /** Email-code step without Continue: six fixed boxes, checked the moment the sixth digit lands (typed or pasted);
   * "Send a new code" is a text link. Off (default): boxes as wide as the button, Continue and an outline resend. */
  codeAuto?: boolean;
  className?: string;
}

// ── Strings

export interface AuthText {
  emailTitle: string;
  emailSub: string;
  emailLabel: string;
  emailPlaceholder: string;
  emailSuggestion: string;
  continue: string;
  or: string;
  google: string;
  back: string;
  busy: string;
  signingIn: string;
  passwordTitle: string;
  passwordLabel: string;
  forgotLink: string;
  signIn: string;
  passkeyTitle: string;
  passkeySub: string;
  passkeyRetry: string;
  usePassword: string;
  googleTitle: string;
  googleSub: string;
  codeTitle: string;
  codeSub: string;
  verifyTitle: string;
  codeLabel: string;
  codeHint: string;
  codeSent: string;
  resendCode: string;
  otherEmail: string;
  signupTitle: string;
  nameLabel: string;
  newPasswordLabel: string;
  createAccount: string;
  twoFactorTitle: string;
  twoFactorSub: string;
  recoverySub: string;
  twoFactorLabel: string;
  recoveryLabel: string;
  twoFactorHint: string;
  useRecovery: string;
  useApp: string;
  forgotTitle: string;
  forgotSub: string;
  sendReset: string;
  sentTitle: string;
  sentSub: string;
  resendReset: string;
  errors: Record<AuthErrorCode, string>;
}

/** Neotax's lines ({email}, {min}, {max}, {seconds} are filled in). */
export const AUTH_TEXT_KO: AuthText = {
  emailTitle: "로그인하기",
  emailSub: "",
  emailLabel: "이메일",
  emailPlaceholder: "이메일 주소",
  emailSuggestion: "혹시 {email} 인가요?",
  continue: "계속하기",
  or: "또는",
  google: "구글로 계속하기",
  back: "돌아가기",
  busy: "처리 중",
  signingIn: "로그인하는 중",
  passwordTitle: "비밀번호 입력하기",
  passwordLabel: "비밀번호",
  forgotLink: "비밀번호를 잊으셨나요?",
  signIn: "로그인하기",
  passkeyTitle: "패스키로 확인해 주세요",
  passkeySub: "브라우저 창에서 {email} 의 패스키로 확인하세요.",
  passkeyRetry: "패스키로 다시 하기",
  usePassword: "비밀번호로 로그인하기",
  googleTitle: "구글로 로그인하기",
  googleSub: "{email} 은 구글 계정으로 만든 계정이에요.",
  codeTitle: "메일함을 확인해 주세요",
  codeSub: "{email} 로 보낸 6자리 코드를 입력하세요.",
  verifyTitle: "이메일 확인",
  codeLabel: "메일로 받은 6자리 코드",
  codeHint: "메일이 안 왔으면 스팸함을 확인해 주세요.",
  codeSent: "새 코드를 보냈어요. 메일이 안 왔으면 스팸함을 확인해 주세요.",
  resendCode: "코드 다시 받기",
  otherEmail: "다른 이메일로",
  signupTitle: "계정 만들기",
  nameLabel: "이름",
  newPasswordLabel: "비밀번호 ({min}자 이상)",
  createAccount: "계정 만들기",
  twoFactorTitle: "2단계 로그인",
  twoFactorSub: "인증 앱에 보이는 6자리 코드를 입력하세요.",
  recoverySub: "등록할 때 받은 복구 코드 하나를 입력하세요. 한 번 쓰면 못 써요.",
  twoFactorLabel: "인증 앱의 6자리 코드",
  recoveryLabel: "복구 코드",
  twoFactorHint: "코드는 30초마다 바뀌어요.",
  useRecovery: "복구 코드로 로그인하기",
  useApp: "인증 앱 코드로 로그인하기",
  forgotTitle: "비밀번호 찾기",
  forgotSub: "가입한 이메일로 재설정 링크를 보내요.",
  sendReset: "재설정 메일 보내기",
  sentTitle: "메일을 보냈어요",
  sentSub: "{email} 의 링크를 열어 새 비밀번호를 정하세요. 링크는 1시간 동안 쓸 수 있어요.",
  resendReset: "메일 다시 보내기",
  errors: {
    generic: "일시적인 오류가 발생했어요",
    email_empty: "이메일 주소를 입력해 주세요.",
    email_invalid: "이메일 형식이 맞지 않아요.",
    email_hangul: "한글이 들어 있어요. 한/영 키를 영문으로 바꿔 입력해 주세요.",
    no_account: "이 이메일로 만든 계정이 없어요.",
    invite_required: "초대받은 이메일로만 들어올 수 있어요.",
    rate_limited: "요청이 너무 많아요. 잠시 뒤 다시 시도해 주세요.",
    password_empty: "비밀번호를 입력해 주세요.",
    password_invalid: "비밀번호가 맞지 않아요.",
    password_too_short: "비밀번호는 {min}자 이상이어야 해요.",
    password_too_long: "비밀번호는 {max}자 이하로 정해 주세요.",
    name_required: "이름을 입력해 주세요.",
    name_too_long: "이름은 50자 이하로 적어 주세요.",
    name_invalid_chars: "이름에 쓸 수 없는 글자가 있어요.",
    account_exists: "이미 계정이 있는 이메일이에요. 돌아가서 로그인해 주세요.",
    invitation_mismatch: "초대받은 이메일로만 가입할 수 있어요.",
    code_incomplete: "6자리 코드를 입력해 주세요.",
    code_invalid: "코드가 맞지 않아요.",
    code_expired: "코드가 만료됐어요. 코드를 다시 받아 주세요.",
    too_many_attempts: "여러 번 틀렸어요. 코드를 다시 받아 주세요.",
    recently_sent: "방금 코드를 보냈어요. 1분 뒤 다시 시도해 주세요.",
    retry_after: "{seconds}초 뒤에 다시 보낼 수 있어요.",
    too_many_codes: "오늘 보낼 수 있는 코드를 다 보냈어요. 내일 다시 시도해 주세요.",
    send_failed: "메일을 보내지 못했어요. 잠시 뒤 다시 시도해 주세요.",
    no_pending_signup: "가입을 마치지 못했어요. 처음부터 다시 해 주세요.",
    google_failed: "구글 로그인을 마치지 못했어요. 다시 시도해 주세요.",
    google_two_factor: "2단계 로그인 계정은 이메일로 로그인해 주세요.",
    passkey_failed: "패스키 확인을 마치지 못했어요. 다시 시도해 주세요.",
    passkey_failed_password: "패스키 확인을 마치지 못했어요. 비밀번호로 로그인해 주세요.",
    two_factor_invalid: "코드가 맞지 않아요. 인증 앱에서 새 코드를 확인해 주세요.",
    recovery_empty: "복구 코드를 입력해 주세요.",
    recovery_invalid: "복구 코드가 맞지 않아요.",
    session_expired: "시간이 지났어요. 처음부터 다시 로그인해 주세요.",
    sign_in_failed: "로그인을 마치지 못했어요. 다시 시도해 주세요.",
  },
};

export const AUTH_TEXT_EN: AuthText = {
  emailTitle: "Log in",
  emailSub: "",
  emailLabel: "Email",
  emailPlaceholder: "Email address",
  emailSuggestion: "Did you mean {email}?",
  continue: "Continue",
  or: "or",
  google: "Continue with Google",
  back: "Back",
  busy: "Working",
  signingIn: "Signing in",
  passwordTitle: "Enter your password",
  passwordLabel: "Password",
  forgotLink: "Forgot password?",
  signIn: "Log in",
  passkeyTitle: "Confirm with your passkey",
  passkeySub: "Use the passkey for {email} in the browser window.",
  passkeyRetry: "Try the passkey again",
  usePassword: "Log in with password",
  googleTitle: "Log in with Google",
  googleSub: "{email} was made with a Google account.",
  codeTitle: "Check your email",
  codeSub: "Enter the 6-digit code we sent to {email}.",
  verifyTitle: "Confirm your email",
  codeLabel: "6-digit code from the email",
  codeHint: "No mail? Check your spam folder.",
  codeSent: "We sent a new code. No mail? Check your spam folder.",
  resendCode: "Send a new code",
  otherEmail: "Use another email",
  signupTitle: "Create your account",
  nameLabel: "Name",
  newPasswordLabel: "Password ({min}+ characters)",
  createAccount: "Create account",
  twoFactorTitle: "Two-step login",
  twoFactorSub: "Enter the 6-digit code in your authenticator app.",
  recoverySub: "Enter one of your recovery codes. Each works once.",
  twoFactorLabel: "6-digit code from the app",
  recoveryLabel: "Recovery code",
  twoFactorHint: "The code changes every 30 seconds.",
  useRecovery: "Use a recovery code",
  useApp: "Use the authenticator app",
  forgotTitle: "Reset your password",
  forgotSub: "We'll mail a reset link to your email.",
  sendReset: "Send reset link",
  sentTitle: "Check your email",
  sentSub: "Open the link sent to {email} to set a new password. It works for 1 hour.",
  resendReset: "Send again",
  errors: {
    generic: "Something went wrong. Try again.",
    email_empty: "Enter your email address.",
    email_invalid: "That doesn't look like an email address.",
    email_hangul: "Korean input is on. Switch to English and type again.",
    no_account: "No account uses this email.",
    invite_required: "Only invited emails can sign in.",
    rate_limited: "Too many tries. Wait a moment and try again.",
    password_empty: "Enter your password.",
    password_invalid: "Wrong password.",
    password_too_short: "Use at least {min} characters.",
    password_too_long: "Use {max} characters or fewer.",
    name_required: "Enter your name.",
    name_too_long: "Use 50 characters or fewer.",
    name_invalid_chars: "The name has characters we can't use.",
    account_exists: "This email already has an account. Go back and log in.",
    invitation_mismatch: "Sign up with the email that was invited.",
    code_incomplete: "Enter the 6-digit code.",
    code_invalid: "Wrong code.",
    code_expired: "The code expired. Get a new one.",
    too_many_attempts: "Too many wrong tries. Get a new code.",
    recently_sent: "We just sent a code. Try again in a minute.",
    retry_after: "You can send again in {seconds} seconds.",
    too_many_codes: "No more codes today. Try again tomorrow.",
    send_failed: "We couldn't send the mail. Try again soon.",
    no_pending_signup: "Sign-up didn't finish. Start again.",
    google_failed: "Google sign-in didn't finish. Try again.",
    google_two_factor: "Two-step accounts log in with email.",
    passkey_failed: "The passkey check didn't finish. Try again.",
    passkey_failed_password: "The passkey check didn't finish. Use your password.",
    two_factor_invalid: "Wrong code. Check the app for a new one.",
    recovery_empty: "Enter a recovery code.",
    recovery_invalid: "Wrong recovery code.",
    session_expired: "Time ran out. Log in again from the start.",
    sign_in_failed: "Sign-in didn't finish. Try again.",
  },
};

// ── Rules (pure, exported for apps and tests)

/**
 * The step after the email. Neotax's order (no account → sign-up; passkey → password → Google-only),
 * with the email code where the password goes for apps that use codes. Only methods the app has on count.
 * "none" = no account and no way to make one here.
 */
export function stepAfterLookup(answer: LookupAnswer, on: AuthMethods): "signup" | "code" | "passkey" | "password" | "google" | "none" {
  if (answer.account === "none") return on.signup ? "signup" : on.code ? "code" : "none";
  const has = answer.methods;
  if (on.passkey && has.passkey) return "passkey";
  if (on.password && has.password) return "password";
  if (on.code && has.code !== false) return "code";
  if (on.google && has.google) return "google";
  return on.password ? "password" : on.code ? "code" : on.google ? "google" : "none";
}

/** After a cancelled or failed passkey: the password if the account has one, else back to the email. */
export function stepAfterPasskeyFailure(has: { password?: boolean } | null, on: AuthMethods): "password" | "email" {
  return on.password && has?.password ? "password" : "email";
}

const EMAIL_LOCAL = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/;
const EMAIL_DOMAIN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
const HANGUL = /[ᄀ-ᇿ㄰-㆏ꥠ-꥿가-힣ힰ-퟿]/;

function isEmailAddress(value: string): boolean {
  if (value.length === 0 || value.length > 254) return false;
  const at = value.lastIndexOf("@");
  if (at <= 0 || at > 64 || value.indexOf("@") !== at) return false;
  return EMAIL_LOCAL.test(value.slice(0, at)) && EMAIL_DOMAIN.test(value.slice(at + 1));
}

// Dubeolsik keyboard: jamo → the Latin key in the same place (double jamo = both keys).
const JAMO_KEYS: Record<string, string> = {
  ㄱ: "r", ㄲ: "R", ㄳ: "rt", ㄴ: "s", ㄵ: "sw", ㄶ: "sg", ㄷ: "e", ㄸ: "E", ㄹ: "f", ㄺ: "fr", ㄻ: "fa", ㄼ: "fq", ㄽ: "ft", ㄾ: "fx",
  ㄿ: "fv", ㅀ: "fg", ㅁ: "a", ㅂ: "q", ㅃ: "Q", ㅄ: "qt", ㅅ: "t", ㅆ: "T", ㅇ: "d", ㅈ: "w", ㅉ: "W", ㅊ: "c", ㅋ: "z", ㅌ: "x", ㅍ: "v",
  ㅎ: "g", ㅏ: "k", ㅐ: "o", ㅑ: "i", ㅒ: "O", ㅓ: "j", ㅔ: "p", ㅕ: "u", ㅖ: "P", ㅗ: "h", ㅘ: "hk", ㅙ: "ho", ㅚ: "hl", ㅛ: "y", ㅜ: "n",
  ㅝ: "nj", ㅞ: "np", ㅟ: "nl", ㅠ: "b", ㅡ: "m", ㅢ: "ml", ㅣ: "l",
};
const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
const JONG = ["", ..."ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"];

/** Text typed with the Korean layout on, back to the Latin keys pressed (「ㅠㅐㅠㅠㅛ」 → "bobby"). */
export function hangulKeysToLatin(value: string): string {
  let out = "";
  for (const ch of value.normalize("NFC")) {
    const code = ch.charCodeAt(0);
    if (code >= 0xac00 && code <= 0xd7a3) {
      const i = code - 0xac00;
      out += JAMO_KEYS[CHO[Math.floor(i / 588)]!]! + JAMO_KEYS[JUNG[Math.floor((i % 588) / 28)]!]!;
      const jong = JONG[i % 28]!;
      if (jong) out += JAMO_KEYS[jong]!;
    } else {
      out += JAMO_KEYS[ch] ?? ch;
    }
  }
  return out;
}

/** The email field's check (Neotax's): trimmed and lower-cased when fine; Hangul input gets a Latin suggestion. */
export function checkEmail(value: string): { ok: true; email: string } | { ok: false; error: "email_empty" | "email_hangul" | "email_invalid"; suggestion: string | null } {
  const email = value.trim();
  if (!email) return { ok: false, error: "email_empty", suggestion: null };
  if (HANGUL.test(email)) {
    const latin = hangulKeysToLatin(email).toLowerCase();
    return { ok: false, error: "email_hangul", suggestion: isEmailAddress(latin) ? latin : null };
  }
  if (!isEmailAddress(email)) return { ok: false, error: "email_invalid", suggestion: null };
  return { ok: true, email: email.toLowerCase() };
}

// ── Parts

const fill = (line: string, vars: Record<string, string | number | undefined>) => line.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k])));

/** Neotax's sizes on the auth-page pills: 40px buttons, 14px/500 text, 20px icons. */
const btn = "gap-2 px-6 text-sm leading-5 [&_svg]:size-5";
/** Neotax's field: 44px, 14px text (16px on touch, so iOS doesn't zoom), a 2px ring on focus, red when wrong. */
const field = (bad: boolean) =>
  cn(
    "h-11 px-4 text-base transition-[border-color,box-shadow] read-only:text-muted-foreground sm:text-sm pointer-coarse:text-base",
    bad ? "border-destructive focus:border-destructive focus:ring-1 focus:ring-destructive" : "hover:border-gray-500 focus:border-ring focus:ring-1 focus:ring-ring",
  );

function Spinner({ label }: { label: string }) {
  return <LoaderCircle className="animate-spin" role="status" aria-label={label} />;
}

/** A button's label, or the spinner while its call runs (never "…중" text). */
function Busy({ busy, label, children }: { busy: boolean; label: string; children: ReactNode }) {
  return busy ? <Spinner label={label} /> : <>{children}</>;
}

/** The colored Google "G". */
export function GoogleIcon() {
  return (
    <svg viewBox="0 0 18 18" aria-hidden="true">
      <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.91c1.7-1.57 2.68-3.88 2.68-6.62z" />
      <path fill="#34A853" d="M9 18c2.43 0 4.47-.81 5.96-2.18l-2.91-2.26c-.81.54-1.84.86-3.05.86-2.34 0-4.33-1.58-5.04-3.71H.96v2.33A9 9 0 0 0 9 18z" />
      <path fill="#FBBC05" d="M3.96 10.71A5.41 5.41 0 0 1 3.68 9c0-.59.1-1.17.28-1.71V4.96H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.04l3-2.33z" />
      <path fill="#EA4335" d="M9 3.58c1.32 0 2.51.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.96L3.96 7.29C4.67 5.16 6.66 3.58 9 3.58z" />
    </svg>
  );
}

/** An error line: under its field, or centered under the title when no field owns it. */
export function AuthError({ children, center }: { children: ReactNode; center?: boolean }) {
  if (!children) return null;
  return (
    <p role="alert" className={cn("mt-1 flex items-start gap-1.5 text-xs leading-4 text-destructive [&_svg]:mt-px [&_svg]:size-3.5 [&_svg]:shrink-0", center && "mt-0 mb-4 justify-center text-center")}>
      <CircleAlert aria-hidden="true" />
      <span>{children}</span>
    </p>
  );
}

function Heading({ title, sub, mark }: { title: ReactNode; sub?: ReactNode; mark?: ReactNode }) {
  return (
    <div className="mb-6 text-center">
      {mark ? <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-gray-100 text-foreground [&_svg]:size-6">{mark}</div> : null}
      {title ? <h1 className="m-0 text-heading-30 text-balance max-sm:text-2xl max-sm:leading-8 max-sm:tracking-[-0.6px]">{title}</h1> : null}
      {sub ? <p className="mt-2 mb-0 text-sm leading-5 text-muted-foreground text-balance">{sub}</p> : null}
    </div>
  );
}

function Label({ htmlFor, children, bad, sr }: { htmlFor: string; children: ReactNode; bad?: boolean; sr?: boolean }) {
  return (
    <label htmlFor={htmlFor} className={cn("mb-1.5 text-sm leading-[14px]", bad && "text-destructive", sr && "sr-only")}>
      {children}
    </label>
  );
}

// ── The flow

type Field = "email" | "password" | "name" | "code";
type Problem = { field: Field | null; message: string };
type BusyKind = null | "submit" | "google" | "resend";

/** Which field a code belongs under (others are centered under the title). */
const FIELD_OF: Partial<Record<string, Field>> = {
  email_empty: "email",
  email_invalid: "email",
  email_hangul: "email",
  password_empty: "password",
  password_invalid: "password",
  password_too_short: "password",
  password_too_long: "password",
  name_required: "name",
  name_too_long: "name",
  name_invalid_chars: "name",
  code_incomplete: "code",
  code_invalid: "code",
  code_expired: "code",
  too_many_attempts: "code",
  retry_after: "code",
  two_factor_invalid: "code",
  recovery_empty: "code",
  recovery_invalid: "code",
};

/** What a missing prop answers: the generic error. */
const NOT_SET: AuthFailure = { ok: false };

const isFailure = (r: unknown): r is AuthFailure => typeof r === "object" && r !== null && (r as { ok?: unknown }).ok === false;

export function AuthFlow(props: AuthFlowProps) {
  const { methods: on, passwordMin = 8, passwordMax = 128 } = props;
  const t: AuthText = { ...AUTH_TEXT_KO, ...props.text, errors: { ...AUTH_TEXT_KO.errors, ...props.text?.errors } };

  const [step, setStep] = useState<AuthStep>(props.finishing ? "routing" : "email");
  const [email, setEmail] = useState(props.initialEmail ?? "");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [code, setCode] = useState("");
  const [purpose, setPurpose] = useState<CodePurpose>("sign-in");
  const [recovery, setRecovery] = useState(false);
  const [known, setKnown] = useState<{ password?: boolean; passkey?: boolean } | null>(null);
  const [passkeyFailed, setPasskeyFailed] = useState(false);
  const [problem, setProblem] = useState<Problem | null>(() => (props.initialError ? { field: null, message: t.errors[props.initialError] ?? t.errors.generic } : null));
  const [suggestion, setSuggestion] = useState<string | null>(null);
  const [notice, setNotice] = useState("");
  const [busy, setBusy] = useState<BusyKind>(null);
  const [forgotFrom, setForgotFrom] = useState<AuthStep>("password");
  /** Bumped on every step change: a reply that comes back after the person moved on is dropped. */
  const gen = useRef(0);
  const busyRef = useRef(false);

  const refs = {
    email: useRef<HTMLInputElement>(null),
    password: useRef<HTMLInputElement>(null),
    name: useRef<HTMLInputElement>(null),
    code: useRef<HTMLInputElement>(null),
  };

  // Each step focuses its first field.
  useLayoutEffect(() => {
    const first: Partial<Record<AuthStep, { current: HTMLInputElement | null }>> = { email: refs.email, forgot: refs.email, password: refs.password, signup: refs.name, code: refs.code, twofactor: refs.code };
    const target = first[step];
    target?.current?.focus();
    // Only when the step changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [step]);

  // `finishing` and `initialError` can change after mount (back from Google, the app decides): spinner while
  // finishing, then the email step with the error, if any.
  const mounted = useRef(false);
  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    if (props.finishing) return go("routing");
    if (step === "routing") go("email");
    if (props.initialError) fail({ error: props.initialError });
    // Only when these props change.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.finishing, props.initialError]);

  // Back from Google with the browser's back button (page restored from the back-forward cache): no spinner.
  useEffect(() => {
    const onShow = (e: PageTransitionEvent) => {
      if (!e.persisted) return;
      busyRef.current = false;
      setBusy(null);
    };
    window.addEventListener("pageshow", onShow);
    return () => window.removeEventListener("pageshow", onShow);
  }, []);

  // The email field's passkey autofill: browsers with a passkey offer it when the field is tapped.
  useEffect(() => {
    if (!on.passkey || !props.passkey || props.finishing || typeof PublicKeyCredential === "undefined") return;
    let alive = true;
    (async () => {
      try {
        if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return;
        const r = await props.passkey!({ email: null, autoFill: true });
        if (alive && r.ok) await finish(r);
      } catch {
        // Cancelled (another check started) or unsupported: stay quiet.
      }
    })();
    return () => {
      alive = false;
    };
    // Once.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const message = (f: AuthFailure | { error: string; retryAfterSeconds?: number; message?: string }) => {
    const key = f.error === "recently_sent" && f.retryAfterSeconds ? "retry_after" : f.error;
    const line = f.message ?? (key && key in t.errors ? t.errors[key as AuthErrorCode] : t.errors.generic);
    return { key: key ?? "generic", text: fill(line, { min: passwordMin, max: passwordMax, seconds: f.retryAfterSeconds, email }) };
  };

  function clear() {
    setProblem(null);
    setSuggestion(null);
    setNotice("");
  }

  /** Change step. Calls still running for the old step are dropped, and their spinner goes. */
  function go(to: AuthStep) {
    gen.current++;
    busyRef.current = false;
    setBusy(null);
    clear();
    setStep(to);
  }

  /** Show a failure: under its field when this step has that field, else centered. */
  function fail(f: AuthFailure | { error: AuthErrorCode; retryAfterSeconds?: number }, owner?: Field) {
    const m = message(f);
    setProblem({ field: owner ?? FIELD_OF[m.key] ?? null, message: m.text });
  }

  /**
   * One server call at a time, with a spinner on the button that started it. `live()` turns false once the
   * step changes (Back, another email): check it after every await. A throw is the generic error.
   */
  async function run(kind: Exclude<BusyKind, null>, work: (live: () => boolean) => Promise<void>) {
    if (busyRef.current) return;
    const g = gen.current;
    const live = () => g === gen.current;
    busyRef.current = true;
    setBusy(kind);
    try {
      await work(live);
    } catch {
      if (live()) setProblem({ field: null, message: t.errors.generic });
    } finally {
      if (live()) {
        busyRef.current = false;
        setBusy(null);
      }
    }
  }

  /** Signed in (or the first factor passed). */
  async function finish(r: AuthResult) {
    if (r.ok && r.twoFactor) {
      setCode("");
      setRecovery(false);
      go("twofactor");
      return;
    }
    go("routing");
    const g = gen.current;
    try {
      await props.onDone();
    } catch {
      if (g !== gen.current) return;
      go("email");
      setProblem({ field: null, message: t.errors.sign_in_failed });
    }
  }

  function badEmail(): string | null {
    const c = checkEmail(email);
    if (c.ok) return c.email;
    fail({ error: c.error }, "email");
    if (c.suggestion) setSuggestion(c.suggestion);
    return null;
  }

  async function toCode(value: string, p: CodePurpose, live: () => boolean) {
    if (!props.sendCode) throw new Error("sendCode missing");
    const r = await props.sendCode(value, p);
    if (!live()) return;
    if (isFailure(r)) return fail(r);
    setPurpose(p);
    setCode("");
    go("code");
  }

  async function startPasskey(has: { password?: boolean } | null, address: string) {
    go("passkey");
    const g = gen.current;
    let r: AuthResult;
    try {
      r = props.passkey ? await props.passkey({ email: address, autoFill: false }) : NOT_SET;
    } catch {
      r = NOT_SET;
    }
    if (g !== gen.current) return;
    if (r.ok) return finish(r);
    if (stepAfterPasskeyFailure(has, on) === "password") {
      setPasskeyFailed(true);
      setPassword("");
      go("password");
      fail({ error: "passkey_failed_password" });
    } else {
      go("email");
      fail({ error: "passkey_failed" });
    }
  }

  function toEmail() {
    setPassword("");
    setPasskeyFailed(false);
    go("email");
  }

  function toPassword() {
    setPasskeyFailed(false);
    setPassword("");
    go("password");
  }

  function onEmail(e: FormEvent) {
    e.preventDefault();
    clear();
    const value = badEmail();
    if (!value) return;
    setEmail(value);
    void run("submit", async (live) => {
      let answer: LookupAnswer;
      if (props.lookup) {
        const r = await props.lookup(value);
        if (!live()) return;
        if (isFailure(r)) return fail(r);
        answer = r;
      } else {
        answer = { account: "exists", methods: { password: true, code: true } };
      }
      const has = answer.account === "exists" ? answer.methods : null;
      setKnown(has);
      const to = stepAfterLookup(answer, on);
      if (to === "none") return fail({ error: "no_account" });
      if (to === "code") return toCode(value, answer.account === "none" ? "sign-up" : "sign-in", live);
      if (to === "passkey") {
        void startPasskey(has, value);
        return;
      }
      setPassword("");
      setPasskeyFailed(false);
      go(to);
    });
  }

  function onPassword(e: FormEvent) {
    e.preventDefault();
    clear();
    if (!password) return fail({ error: "password_empty" });
    void run("submit", async (live) => {
      const r = props.password ? await props.password({ email, password }) : NOT_SET;
      if (!live()) return;
      if (isFailure(r)) return fail(r);
      await finish(r);
    });
  }

  function onSignup(e: FormEvent) {
    e.preventDefault();
    clear();
    if (!name.trim()) return fail({ error: "name_required" });
    if (on.password && password.length < passwordMin) return fail({ error: "password_too_short" });
    if (on.password && password.length > passwordMax) return fail({ error: "password_too_long" });
    void run("submit", async (live) => {
      const r = props.signup
        ? await props.signup({ email, name: name.trim(), ...(on.password ? { password } : {}) })
        : NOT_SET;
      if (!live()) return;
      if (isFailure(r)) return fail(r);
      if (r.next === "signed_in") return finish({ ok: true });
      setPurpose("sign-up");
      setCode("");
      go("code");
    });
  }

  function onCode(e: FormEvent) {
    e.preventDefault();
    verify(code);
  }

  function verify(code: string) {
    clear();
    if (code.length !== 6) {
      fail({ error: "code_incomplete" });
      refs.code.current?.focus();
      return;
    }
    void run("submit", async (live) => {
      const r = props.verifyCode
        ? await props.verifyCode({ email, code, purpose, ...(purpose === "sign-up" && password ? { password } : {}) })
        : NOT_SET;
      if (!live()) return;
      if (isFailure(r)) {
        // A wrong or expired code is useless: empty the boxes for the next one.
        if (r.error === "code_invalid" || r.error === "code_expired") setCode("");
        fail(r, "code");
        refs.code.current?.focus();
        return;
      }
      await finish(r);
    });
  }

  function resend() {
    clear();
    void run("resend", async (live) => {
      if (!props.sendCode) throw new Error("sendCode missing");
      const r = await props.sendCode(email, purpose);
      if (!live()) return;
      if (isFailure(r)) return fail(r, "code");
      setCode("");
      setNotice(t.codeSent);
      refs.code.current?.focus();
    });
  }

  function onTwoFactor(e: FormEvent) {
    e.preventDefault();
    clear();
    const value = recovery ? code.trim() : code;
    if (recovery ? !value : value.length !== 6) {
      fail({ error: recovery ? "recovery_empty" : "code_incomplete" });
      refs.code.current?.focus();
      return;
    }
    void run("submit", async (live) => {
      const r = props.twoFactor ? await props.twoFactor({ code: value, recovery }) : NOT_SET;
      if (!live()) return;
      if (isFailure(r)) return fail(r.error || r.message ? r : { error: recovery ? "recovery_invalid" : "two_factor_invalid" }, "code");
      await finish({ ok: true });
    });
  }

  function sendReset(e?: FormEvent) {
    e?.preventDefault();
    clear();
    const value = badEmail();
    if (!value) return;
    setEmail(value);
    const from = step;
    void run(from === "sent" ? "resend" : "submit", async (live) => {
      const r = props.resetPassword ? await props.resetPassword(value) : NOT_SET;
      if (!live()) return;
      if (isFailure(r)) return fail(r);
      if (from !== "sent") go("sent");
    });
  }

  function startGoogle() {
    if (busyRef.current) return;
    clear();
    const g = gen.current;
    busyRef.current = true;
    setBusy("google");
    (async () => {
      let r: AuthResult | void;
      try {
        r = props.google ? await props.google() : NOT_SET;
      } catch {
        r = { ok: false, error: "google_failed" };
      }
      if (g !== gen.current) return;
      if (!r) return; // Leaving for Google: keep the spinner (pageshow clears it if they come back).
      busyRef.current = false;
      setBusy(null);
      if (isFailure(r)) fail({ ...r, error: r.error ?? "google_failed" });
      else await finish(r);
    })();
  }

  const errorOf = (f: Field) => (problem && problem.field === f ? problem.message : "");
  const formError = (fields: Field[]) => (problem && (problem.field === null || !fields.includes(problem.field)) ? <AuthError center>{problem.message}</AuthError> : null);
  const submitting = busy === "submit";

  const googleButton = (primary: boolean) => (
    <AuthButton type="button" primary={primary} className={btn} disabled={busy === "google"} aria-busy={busy === "google" || undefined} onClick={startGoogle}>
      <Busy busy={busy === "google"} label={t.busy}>
        <GoogleIcon />
        {t.google}
      </Busy>
    </AuthButton>
  );

  const auto = Boolean(props.codeAuto);
  const link = "cursor-pointer border-0 bg-transparent p-0 text-inherit underline underline-offset-2 hover:text-foreground disabled:cursor-default disabled:opacity-60";

  const submit = (label: string) => (
    <AuthButton type="submit" primary className={btn} disabled={submitting} aria-busy={submitting || undefined}>
      <Busy busy={submitting} label={t.busy}>
        {label}
      </Busy>
    </AuthButton>
  );

  const outline = (label: ReactNode, onClick: () => void, extra?: { busy?: boolean }) => (
    <AuthButton type="button" className={btn} onClick={onClick} disabled={extra?.busy} aria-busy={extra?.busy || undefined}>
      <Busy busy={Boolean(extra?.busy)} label={t.busy}>
        {label}
      </Busy>
    </AuthButton>
  );

  const emailField = (id: string, labelled: boolean) => (
    <div className="flex flex-col gap-1">
      <Label htmlFor={id} sr={!labelled} bad={Boolean(errorOf("email"))}>
        {t.emailLabel}
      </Label>
      <AuthField
        id={id}
        ref={refs.email}
        type="email"
        name="email"
        placeholder={t.emailPlaceholder}
        autoComplete={on.passkey ? "username webauthn" : "username"}
        value={email}
        onChange={(e) => {
          setEmail(e.target.value);
          setSuggestion(null);
        }}
        aria-invalid={Boolean(errorOf("email")) || undefined}
        className={field(Boolean(errorOf("email")))}
      />
      <AuthError>{errorOf("email")}</AuthError>
      {suggestion ? (
        <p className="mt-1 text-xs leading-4 text-muted-foreground">
          <button
            type="button"
            className="cursor-pointer border-0 bg-transparent p-0 text-foreground underline underline-offset-2"
            onClick={() => {
              setEmail(suggestion);
              clear();
              refs.email.current?.focus();
            }}
          >
            {fill(t.emailSuggestion, { email: suggestion })}
          </button>
        </p>
      ) : null}
    </div>
  );

  const readOnlyEmail = (
    <div className="flex flex-col gap-1">
      <Label htmlFor="auth-email-shown">{t.emailLabel}</Label>
      <AuthField id="auth-email-shown" type="email" name="email" autoComplete="username" value={email} readOnly className={field(false)} />
    </div>
  );

  const passwordField = (id: string, label: string, autoComplete: string, aside?: ReactNode, focus?: boolean) => (
    <div className="flex flex-col gap-1">
      <div className="flex items-baseline justify-between">
        <Label htmlFor={id} bad={Boolean(errorOf("password"))}>
          {label}
        </Label>
        {aside}
      </div>
      <AuthField
        id={id}
        ref={focus ? refs.password : undefined}
        type="password"
        name="password"
        autoComplete={autoComplete}
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        aria-invalid={Boolean(errorOf("password")) || undefined}
        className={field(Boolean(errorOf("password")))}
      />
      <AuthError>{errorOf("password")}</AuthError>
    </div>
  );

  const usesEmail = Boolean(on.code || on.password || on.passkey || on.signup);
  let body: ReactNode;

  switch (step) {
    case "email":
      body = (
        <>
          <Heading title={t.emailTitle} sub={t.emailSub} />
          {formError(usesEmail ? ["email"] : [])}
          {usesEmail ? (
            <form className="m-0 flex flex-col gap-4" noValidate onSubmit={onEmail}>
              {emailField("auth-email", false)}
              {submit(t.continue)}
            </form>
          ) : null}
          {on.google ? (
            <>
              {usesEmail ? <AuthDivider className="my-4 text-xs leading-4">{t.or}</AuthDivider> : null}
              {googleButton(false)}
            </>
          ) : null}
        </>
      );
      break;

    case "password":
      body = (
        <>
          <Heading title={t.passwordTitle} />
          {formError(["password"])}
          <form className="m-0 flex flex-col gap-4" noValidate onSubmit={onPassword}>
            {readOnlyEmail}
            {passwordField(
              "auth-password",
              t.passwordLabel,
              "current-password",
              props.resetPassword ? (
                <button
                  type="button"
                  className="cursor-pointer border-0 bg-transparent p-0 text-[13px] leading-[14px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
                  onClick={() => {
                    setForgotFrom("password");
                    go("forgot");
                  }}
                >
                  {t.forgotLink}
                </button>
              ) : null,
              true,
            )}
            <div className="flex flex-col gap-3">
              {submit(t.signIn)}
              {passkeyFailed ? outline(t.passkeyRetry, () => void startPasskey(known, email)) : outline(t.back, toEmail)}
            </div>
          </form>
        </>
      );
      break;

    case "passkey":
      body = (
        <>
          <div role="status">
            <Heading mark={<LoaderCircle className="animate-spin" />} title={t.passkeyTitle} sub={fill(t.passkeySub, { email })} />
          </div>
          <div className="flex flex-col gap-3">{on.password && known?.password ? outline(t.usePassword, toPassword) : outline(t.back, toEmail)}</div>
        </>
      );
      break;

    case "google":
      body = (
        <>
          <Heading title={t.googleTitle} sub={fill(t.googleSub, { email })} />
          {formError([])}
          <div className="flex flex-col gap-3">
            {googleButton(true)}
            {outline(t.back, toEmail)}
          </div>
        </>
      );
      break;

    case "signup":
      body = (
        <>
          <Heading title={t.signupTitle} />
          {formError(on.password ? ["name", "password"] : ["name"])}
          <form className="m-0 flex flex-col gap-4" noValidate onSubmit={onSignup}>
            {readOnlyEmail}
            <div className="flex flex-col gap-1">
              <Label htmlFor="auth-name" bad={Boolean(errorOf("name"))}>
                {t.nameLabel}
              </Label>
              <AuthField
                id="auth-name"
                ref={refs.name}
                name="name"
                autoComplete="name"
                maxLength={50}
                value={name}
                onChange={(e) => setName(e.target.value)}
                aria-invalid={Boolean(errorOf("name")) || undefined}
                className={field(Boolean(errorOf("name")))}
              />
              <AuthError>{errorOf("name")}</AuthError>
            </div>
            {on.password ? passwordField("auth-new-password", fill(t.newPasswordLabel, { min: passwordMin }), "new-password") : null}
            <div className="flex flex-col gap-3">
              {submit(t.createAccount)}
              {outline(t.back, toEmail)}
            </div>
          </form>
        </>
      );
      break;

    case "code": {
      const bad = errorOf("code");
      body = (
        <>
          <Heading title={purpose === "sign-up" ? t.verifyTitle : t.codeTitle} sub={fill(t.codeSub, { email })} />
          {formError(["code"])}
          <form className="m-0 flex flex-col gap-4" noValidate onSubmit={onCode}>
            <div className="flex justify-center">
              <AuthCodeBoxes
                id="auth-code"
                name="code"
                inputRef={refs.code}
                value={code}
                label={t.codeLabel}
                bad={Boolean(bad)}
                fill={!auto}
                readOnly={auto && submitting}
                onChange={(v) => {
                  if (problem?.field === "code") setProblem(null);
                  setCode(v);
                  // codeAuto: the sixth digit is the submit (once per code — a wrong one empties the boxes).
                  if (auto && v.length === 6 && v !== code && !submitting) verify(v);
                }}
              />
            </div>
            {bad ? (
              <div className="-mt-3 flex justify-center">
                <AuthError>{bad}</AuthError>
              </div>
            ) : auto && submitting ? (
              <div className="flex h-4 justify-center text-muted-foreground [&_svg]:size-4">
                <Spinner label={t.busy} />
              </div>
            ) : (
              <p className="m-0 text-center text-xs leading-4 text-muted-foreground">{notice || t.codeHint}</p>
            )}
            {auto ? (
              <p className="m-0 text-center text-xs leading-4 text-muted-foreground">
                <button type="button" className={link} onClick={resend} disabled={busy === "resend"} aria-busy={busy === "resend" || undefined}>
                  {t.resendCode}
                </button>
              </p>
            ) : (
              <div className="flex flex-col gap-3">
                {submit(t.continue)}
                {outline(t.resendCode, resend, { busy: busy === "resend" })}
              </div>
            )}
          </form>
          <p className={cn("mb-0 text-center text-xs leading-4 text-muted-foreground", auto ? "mt-2" : "mt-4")}>
            <button type="button" className={link} onClick={toEmail}>
              {t.otherEmail}
            </button>
          </p>
        </>
      );
      break;
    }

    case "twofactor": {
      const bad = errorOf("code");
      body = (
        <>
          <Heading title={t.twoFactorTitle} sub={recovery ? t.recoverySub : t.twoFactorSub} />
          {formError(["code"])}
          <form className="m-0 flex flex-col gap-4" noValidate onSubmit={onTwoFactor}>
            {recovery ? (
              <div className="flex flex-col gap-1">
                <Label htmlFor="auth-recovery" bad={Boolean(bad)}>
                  {t.recoveryLabel}
                </Label>
                <AuthField
                  id="auth-recovery"
                  ref={refs.code}
                  autoComplete="off"
                  autoCapitalize="off"
                  spellCheck={false}
                  value={code}
                  onChange={(e) => setCode(e.target.value)}
                  aria-invalid={Boolean(bad) || undefined}
                  className={field(Boolean(bad))}
                />
                <AuthError>{bad}</AuthError>
              </div>
            ) : (
              <>
                <div className="flex justify-center">
                  <AuthCodeBoxes
                    id="auth-two-factor"
                    inputRef={refs.code}
                    value={code}
                    label={t.twoFactorLabel}
                    bad={Boolean(bad)}
                    fill
                    onChange={(v) => {
                      if (problem?.field === "code") setProblem(null);
                      setCode(v);
                    }}
                  />
                </div>
                {bad ? (
                  <div className="-mt-3 flex justify-center">
                    <AuthError>{bad}</AuthError>
                  </div>
                ) : (
                  <p className="m-0 text-center text-xs leading-4 text-muted-foreground">{t.twoFactorHint}</p>
                )}
              </>
            )}
            <div className="flex flex-col gap-3">
              {submit(t.signIn)}
              {outline(recovery ? t.useApp : t.useRecovery, () => {
                clear();
                setCode("");
                setRecovery((r) => !r);
                setTimeout(() => refs.code.current?.focus(), 0);
              })}
            </div>
          </form>
        </>
      );
      break;
    }

    case "forgot":
      body = (
        <>
          <Heading title={t.forgotTitle} sub={t.forgotSub} />
          {formError(["email"])}
          <form className="m-0 flex flex-col gap-4" noValidate onSubmit={sendReset}>
            {emailField("auth-forgot-email", false)}
            <div className="flex flex-col gap-3">
              {submit(t.sendReset)}
              {outline(t.back, () => go(forgotFrom))}
            </div>
          </form>
        </>
      );
      break;

    case "sent":
      body = (
        <>
          <div role="status">
            <Heading mark={<MailOpen />} title={t.sentTitle} sub={fill(t.sentSub, { email })} />
          </div>
          {formError([])}
          <div className="flex flex-col gap-3">{outline(t.resendReset, () => sendReset(), { busy: busy === "resend" })}</div>
        </>
      );
      break;

    case "routing":
      body = (
        <div role="status" aria-label={t.signingIn} className="flex justify-center [&_svg]:size-6">
          <LoaderCircle className="animate-spin" aria-hidden="true" />
        </div>
      );
      break;
  }

  const column = (
    <section data-step={step} className={cn("flex w-full flex-col text-foreground [word-break:keep-all]", props.className)}>
      {body}
    </section>
  );
  if (props.bare) return column;
  return (
    <AuthLayout brand={props.brand} app={props.app} footer={props.footer}>
      {column}
    </AuthLayout>
  );
}

Source on GitHub