Six-digit codes are typed into six boxes: the sign-in's second step, turning two-factor on or off in settings, and confirming an email address. It is shadcn's Input OTP (components/ui/input-otp.tsx, on input-otp 1.4.2) in g1t's tokens: two groups of three with a rule between, the current box ringed in lavender with a blinking caret (still under reduced motion), every box red when the code was refused. One real input sits under the boxes with the field's id, name="code" and autocomplete="one-time-code", so password managers and phones' code suggestions fill it and the form posts what it did before. A pasted "123 456" or "123-456" lands as six digits. On sign-in and email confirmation the form is sent as soon as the sixth digit is in, and a refused code is cleared and the boxes focused for the next. Where a recovery code is also taken (signing in, turning two-factor off), "Use a recovery code" switches to the text field, and a recovery code pasted into the boxes switches by itself with the code carried across, so one is never cut to digits (lib/otp.ts looksLikeRecoveryCode, codeDigits). components/one-time-code.tsx holds CodeSlots and TwoFactorCodeField. Checked: apps/web tsc -b and npm test (993; new: pasted codes and telling a recovery code apart; the confirm page's field test reads the boxes), and the fields rendered alone in headless Chrome driven over CDP: focus on load, typing 123456 submitted 123456, pasting "987 654" gave 987654 and submitted, pasting k7m2q-9xw4d into the boxes moved it whole to the Recovery code field. Not tried with a real password manager or on a phone.
- G1t-Session:
- ses_01m50mr529evxafazhzv6vasds
11 files+268−520/11 viewed
| 86 | 86 | --animate-sheet-out-left: sheet-out-left 0.16s ease-in forwards; | |
| 87 | 87 | /* "Thinking…" before g1t's first words: lavender sweeping across (components/ai-elements/loader.tsx). */ | |
| 88 | 88 | --animate-shimmer: shimmer 2.2s linear infinite; | |
| 89 | + | /* The caret in a one-time-code box (components/ui/input-otp.tsx). */ | |
| 90 | + | --animate-caret-blink: caret-blink 1.1s ease-out infinite; | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | @keyframes caret-blink { | |
| 94 | + | 0%, | |
| 95 | + | 70%, | |
| 96 | + | 100% { | |
| 97 | + | opacity: 1; | |
| 98 | + | } | |
| 99 | + | 20%, | |
| 100 | + | 50% { | |
| 101 | + | opacity: 0; | |
| 102 | + | } | |
| 89 | 103 | } | |
| 90 | 104 | ||
| 91 | 105 | @keyframes shimmer { |
| 1 | + | /** | |
| 2 | + | * Six-digit codes, as six boxes in two threes: the sign-in's second step, | |
| 3 | + | * two-factor settings and email confirmation. One real input underneath | |
| 4 | + | * (components/ui/input-otp.tsx) carries `autocomplete="one-time-code"` | |
| 5 | + | * and the field's stable id, so password managers and phones' code | |
| 6 | + | * suggestions fill it, and the form posts `code` as before. | |
| 7 | + | * | |
| 8 | + | * Where a recovery code is also taken (`k7m2q-9xw4d`), it gets the plain | |
| 9 | + | * text field instead: a link switches between them, and a recovery code | |
| 10 | + | * pasted into the boxes switches by itself, the code carried across, so | |
| 11 | + | * one is never cut to six digits. | |
| 12 | + | */ | |
| 13 | + | import { REGEXP_ONLY_DIGITS } from "input-otp"; | |
| 14 | + | import { type ClipboardEvent, useEffect, useRef, useState } from "react"; | |
| 15 | + | import { useNavigation } from "react-router"; | |
| 16 | + | ||
| 17 | + | import { Field, Input } from "./ui"; | |
| 18 | + | import { Button } from "./ui/button"; | |
| 19 | + | import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "./ui/input-otp"; | |
| 20 | + | import { codeDigits, looksLikeRecoveryCode, otpInputProps } from "../lib/otp"; | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * The six boxes. `submit` sends the form once the sixth digit is in. | |
| 24 | + | * `onRecoveryCode`, when given, takes a recovery code pasted into them. | |
| 25 | + | */ | |
| 26 | + | export function CodeSlots({ | |
| 27 | + | id, | |
| 28 | + | name = "code", | |
| 29 | + | autoFocus, | |
| 30 | + | submit = false, | |
| 31 | + | invalid = false, | |
| 32 | + | describedBy, | |
| 33 | + | onRecoveryCode, | |
| 34 | + | }: { | |
| 35 | + | id: string; | |
| 36 | + | name?: string; | |
| 37 | + | autoFocus?: boolean; | |
| 38 | + | submit?: boolean; | |
| 39 | + | invalid?: boolean; | |
| 40 | + | describedBy?: string; | |
| 41 | + | onRecoveryCode?: (code: string) => void; | |
| 42 | + | }) { | |
| 43 | + | const input = useRef<HTMLInputElement>(null); | |
| 44 | + | const [value, setValue] = useState(""); | |
| 45 | + | // A code that was sent and refused is cleared, ready for the next one. | |
| 46 | + | const navigation = useNavigation(); | |
| 47 | + | const sent = useRef(false); | |
| 48 | + | useEffect(() => { | |
| 49 | + | if (navigation.state !== "idle") { | |
| 50 | + | sent.current = true; | |
| 51 | + | return; | |
| 52 | + | } | |
| 53 | + | if (sent.current && invalid) { | |
| 54 | + | setValue(""); | |
| 55 | + | input.current?.focus(); | |
| 56 | + | } | |
| 57 | + | sent.current = false; | |
| 58 | + | }, [navigation.state, invalid]); | |
| 59 | + | // Seen before the boxes' own paste handling, so a recovery code is not cut to digits first. | |
| 60 | + | const onPasteCapture = (event: ClipboardEvent) => { | |
| 61 | + | const pasted = event.clipboardData.getData("text"); | |
| 62 | + | if (!onRecoveryCode || !looksLikeRecoveryCode(pasted)) return; | |
| 63 | + | event.preventDefault(); | |
| 64 | + | event.stopPropagation(); | |
| 65 | + | onRecoveryCode(pasted.trim()); | |
| 66 | + | }; | |
| 67 | + | return ( | |
| 68 | + | <div onPasteCapture={onPasteCapture}> | |
| 69 | + | <InputOTP | |
| 70 | + | ref={input} | |
| 71 | + | id={id} | |
| 72 | + | name={name} | |
| 73 | + | maxLength={6} | |
| 74 | + | pattern={REGEXP_ONLY_DIGITS} | |
| 75 | + | pasteTransformer={codeDigits} | |
| 76 | + | value={value} | |
| 77 | + | onChange={setValue} | |
| 78 | + | onComplete={() => { | |
| 79 | + | if (submit) input.current?.form?.requestSubmit(); | |
| 80 | + | }} | |
| 81 | + | autoComplete="one-time-code" | |
| 82 | + | inputMode="numeric" | |
| 83 | + | autoFocus={autoFocus} | |
| 84 | + | required | |
| 85 | + | aria-invalid={invalid || undefined} | |
| 86 | + | aria-describedby={describedBy} | |
| 87 | + | > | |
| 88 | + | <InputOTPGroup> | |
| 89 | + | <InputOTPSlot index={0} /> | |
| 90 | + | <InputOTPSlot index={1} /> | |
| 91 | + | <InputOTPSlot index={2} /> | |
| 92 | + | </InputOTPGroup> | |
| 93 | + | <InputOTPSeparator /> | |
| 94 | + | <InputOTPGroup> | |
| 95 | + | <InputOTPSlot index={3} /> | |
| 96 | + | <InputOTPSlot index={4} /> | |
| 97 | + | <InputOTPSlot index={5} /> | |
| 98 | + | </InputOTPGroup> | |
| 99 | + | </InputOTP> | |
| 100 | + | </div> | |
| 101 | + | ); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** | |
| 105 | + | * A two-factor code: the boxes for the code from an authenticator app, | |
| 106 | + | * or, with `recovery`, a way across to a recovery code's text field. | |
| 107 | + | * Identity takes either kind whichever field it came from. | |
| 108 | + | */ | |
| 109 | + | export function TwoFactorCodeField({ | |
| 110 | + | id = "totp-code", | |
| 111 | + | label = "Authentication code", | |
| 112 | + | hint, | |
| 113 | + | recovery = false, | |
| 114 | + | autoFocus, | |
| 115 | + | submit, | |
| 116 | + | invalid, | |
| 117 | + | }: { | |
| 118 | + | id?: string; | |
| 119 | + | label?: string; | |
| 120 | + | hint?: string; | |
| 121 | + | /** Whether a recovery code is taken here too. */ | |
| 122 | + | recovery?: boolean; | |
| 123 | + | autoFocus?: boolean; | |
| 124 | + | submit?: boolean; | |
| 125 | + | invalid?: boolean; | |
| 126 | + | }) { | |
| 127 | + | const [usingRecovery, setUsingRecovery] = useState<false | { initial: string }>(false); | |
| 128 | + | return ( | |
| 129 | + | <div className="space-y-2"> | |
| 130 | + | <Field label={usingRecovery ? "Recovery code" : label} htmlFor={id} hint={hint}> | |
| 131 | + | {usingRecovery ? ( | |
| 132 | + | <Input | |
| 133 | + | {...otpInputProps({ id, recovery: true })} | |
| 134 | + | defaultValue={usingRecovery.initial} | |
| 135 | + | aria-invalid={invalid || undefined} | |
| 136 | + | required | |
| 137 | + | autoFocus={autoFocus || usingRecovery.initial !== ""} | |
| 138 | + | /> | |
| 139 | + | ) : ( | |
| 140 | + | <CodeSlots | |
| 141 | + | id={id} | |
| 142 | + | autoFocus={autoFocus} | |
| 143 | + | submit={submit} | |
| 144 | + | invalid={invalid} | |
| 145 | + | onRecoveryCode={recovery ? (code) => setUsingRecovery({ initial: code }) : undefined} | |
| 146 | + | /> | |
| 147 | + | )} | |
| 148 | + | </Field> | |
| 149 | + | {recovery && ( | |
| 150 | + | <Button type="button" variant="link" size="inline" className="text-sm" onClick={() => setUsingRecovery(usingRecovery ? false : { initial: "" })}> | |
| 151 | + | {usingRecovery ? "Use a code from your app" : "Use a recovery code"} | |
| 152 | + | </Button> | |
| 153 | + | )} | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } |
| 1 | + | import { OTPInput, OTPInputContext } from "input-otp"; | |
| 2 | + | import { type ComponentProps, useContext } from "react"; | |
| 3 | + | ||
| 4 | + | import { cn } from "../../lib/cn"; | |
| 5 | + | ||
| 6 | + | // shadcn/ui's input OTP, styled with g1t's tokens: a one-time code drawn as | |
| 7 | + | // a box per character over one real <input>, so autofill, password | |
| 8 | + | // managers and pasting work as with any field, and the form posts the | |
| 9 | + | // input's `name` as one value. Put slots in an InputOTPGroup, the groups | |
| 10 | + | // apart with an InputOTPSeparator. `aria-invalid` on the InputOTP marks | |
| 11 | + | // every box. | |
| 12 | + | ||
| 13 | + | export function InputOTP({ className, containerClassName, ...props }: ComponentProps<typeof OTPInput>) { | |
| 14 | + | return ( | |
| 15 | + | <OTPInput | |
| 16 | + | data-slot="input-otp" | |
| 17 | + | containerClassName={cn("group/otp flex items-center gap-2 has-disabled:opacity-50", containerClassName)} | |
| 18 | + | className={cn("disabled:cursor-not-allowed", className)} | |
| 19 | + | {...props} | |
| 20 | + | /> | |
| 21 | + | ); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function InputOTPGroup({ className, ...props }: ComponentProps<"div">) { | |
| 25 | + | return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export function InputOTPSlot({ index, className, ...props }: ComponentProps<"div"> & { index: number }) { | |
| 29 | + | const context = useContext(OTPInputContext); | |
| 30 | + | const { char, hasFakeCaret, isActive } = context?.slots[index] ?? {}; | |
| 31 | + | return ( | |
| 32 | + | <div | |
| 33 | + | data-slot="input-otp-slot" | |
| 34 | + | data-active={isActive} | |
| 35 | + | className={cn( | |
| 36 | + | "relative flex h-12 w-10 items-center justify-center border-y border-r border-line bg-bg font-mono text-xl text-fg tabular-nums transition-[border-color,box-shadow] sm:h-13 sm:w-11", | |
| 37 | + | "first:rounded-l-md first:border-l last:rounded-r-md", | |
| 38 | + | "data-[active=true]:z-10 data-[active=true]:border-accent-dim data-[active=true]:ring-2 data-[active=true]:ring-accent/25", | |
| 39 | + | "group-has-aria-invalid/otp:border-danger/70", | |
| 40 | + | className, | |
| 41 | + | )} | |
| 42 | + | {...props} | |
| 43 | + | > | |
| 44 | + | {char} | |
| 45 | + | {hasFakeCaret && ( | |
| 46 | + | <div aria-hidden="true" className="pointer-events-none absolute inset-0 flex items-center justify-center"> | |
| 47 | + | <div className="h-5 w-px animate-caret-blink bg-fg motion-reduce:animate-none" /> | |
| 48 | + | </div> | |
| 49 | + | )} | |
| 50 | + | </div> | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | export function InputOTPSeparator(props: ComponentProps<"div">) { | |
| 55 | + | return ( | |
| 56 | + | <div data-slot="input-otp-separator" role="separator" className="h-px w-2.5 shrink-0 bg-line-strong" {...props} /> | |
| 57 | + | ); | |
| 58 | + | } |
| 75 | 75 | ||
| 76 | 76 | test("the code field is a one-time code typed with a number pad", () => { | |
| 77 | 77 | const page = readFileSync(new URL("../routes/confirm-email.tsx", import.meta.url), "utf8"); | |
| 78 | − | const input = /<Input[\s\S]*?name="code"[\s\S]*?\/>/.exec(page)?.[0] ?? ""; | |
| 78 | + | assert.match(page, /<CodeSlots id="email-verification-code"/); | |
| 79 | + | // The boxes are one real input that posts `code` and that autofill finds. | |
| 80 | + | const slots = readFileSync(new URL("../components/one-time-code.tsx", import.meta.url), "utf8"); | |
| 81 | + | const input = /<InputOTP\b[\s\S]*?<\/InputOTP>/.exec(slots)?.[0] ?? ""; | |
| 82 | + | assert.match(input, /name=\{name\}/); | |
| 83 | + | assert.match(slots, /name = "code"/); | |
| 79 | 84 | assert.match(input, /autoComplete="one-time-code"/); | |
| 80 | 85 | assert.match(input, /inputMode="numeric"/); | |
| 81 | − | assert.match(input, /pattern="\[0-9 -\]\*"/); | |
| 86 | + | assert.match(input, /pattern=\{REGEXP_ONLY_DIGITS\}/); | |
| 82 | 87 | // Either route: the page says the link in the email works too. | |
| 83 | 88 | assert.match(page, /link in the email/); | |
| 84 | 89 | }); |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { otpInputProps } from "./otp.ts"; | |
| 4 | + | import { codeDigits, looksLikeRecoveryCode, otpInputProps } from "./otp.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("one-time-code fields are findable by password managers and carry no ignore attributes", () => { | |
| 7 | 7 | for (const recovery of [false, true]) { | |
| ⋯ | |||
| 30 | 30 | assert.equal(props.pattern, undefined); | |
| 31 | 31 | for (const code of ["k7m2q-9xw4d", "K7M2Q 9XW4D", "123456"]) assert.ok(code.length <= props.maxLength); | |
| 32 | 32 | }); | |
| 33 | + | ||
| 34 | + | test("a code pasted into the boxes loses its spaces and dashes; a recovery code is told apart", () => { | |
| 35 | + | assert.equal(codeDigits("123 456"), "123456"); | |
| 36 | + | assert.equal(codeDigits(" 123-456 "), "123456"); | |
| 37 | + | for (const code of ["k7m2q-9xw4d", "K7M2Q 9XW4D", " k7m2q9xw4d\n"]) assert.ok(looksLikeRecoveryCode(code), code); | |
| 38 | + | for (const text of ["123456", "123 456", "", "hello there, this is far too long to be a code"]) assert.ok(!looksLikeRecoveryCode(text), text); | |
| 39 | + | }); | |
| 23 | 23 | placeholder: recovery ? "k7m2q-9xw4d" : "123 456", | |
| 24 | 24 | } as const; | |
| 25 | 25 | } | |
| 26 | + | ||
| 27 | + | /** A code as pasted, without the spaces and dashes people copy with it ("123 456", "123-456"). */ | |
| 28 | + | export function codeDigits(pasted: string): string { | |
| 29 | + | return pasted.replace(/[\s-]/g, ""); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Whether pasted text is a recovery code (`k7m2q-9xw4d`) rather than an app's six digits: it has letters in it. */ | |
| 33 | + | export function looksLikeRecoveryCode(pasted: string): boolean { | |
| 34 | + | const text = codeDigits(pasted.trim()); | |
| 35 | + | return /[a-z]/i.test(text) && /^[a-z0-9]{6,20}$/i.test(text); | |
| 36 | + | } |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/confirm-email"; | |
| 7 | 7 | import { page } from "../lib/meta"; | |
| 8 | + | import { CodeSlots } from "../components/one-time-code"; | |
| 8 | 9 | import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 9 | 10 | import { Button } from "../components/ui/button"; | |
| 10 | 11 | import { Hint } from "../components/ui/hint"; | |
| ⋯ | |||
| 129 | 130 | <Form method="post" className="mt-6 space-y-4"> | |
| 130 | 131 | <input type="hidden" name="intent" value="confirm" /> | |
| 131 | 132 | <Field label="Confirmation code" htmlFor="email-verification-code" hint={`The code and the link work for ${MINUTES} minutes.`}> | |
| 132 | − | <Input | |
| 133 | − | id="email-verification-code" | |
| 134 | − | name="code" | |
| 135 | − | type="text" | |
| 136 | − | autoCapitalize="none" | |
| 137 | − | autoCorrect="off" | |
| 138 | − | spellCheck={false} | |
| 139 | − | autoComplete="one-time-code" | |
| 140 | − | inputMode="numeric" | |
| 141 | − | pattern="[0-9 -]*" | |
| 142 | − | maxLength={9} | |
| 143 | − | required | |
| 144 | − | autoFocus | |
| 145 | − | placeholder="123456" | |
| 146 | − | aria-describedby="code-error" | |
| 147 | − | style={{ fontSize: "1.5rem", letterSpacing: "0.4em", fontFamily: "var(--font-mono)", textAlign: "center" }} | |
| 148 | − | /> | |
| 133 | + | {/* Sent as soon as the sixth digit is in. */} | |
| 134 | + | <CodeSlots id="email-verification-code" autoFocus submit invalid={errorFor("confirm") != null} describedBy="code-error" /> | |
| 149 | 135 | </Field> | |
| 150 | 136 | <div id="code-error"> | |
| 151 | 137 | <ErrorText>{errorFor("confirm")}</ErrorText> | |
| 1 | − | import { useState } from "react"; | |
| 2 | 1 | import { Form, Link, redirect } from "react-router"; | |
| 3 | 2 | ||
| 4 | 3 | import type { Route } from "./+types/login-two-factor"; | |
| 5 | 4 | import { AuthCard } from "../components/auth-card"; | |
| 6 | − | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | − | import { Button } from "../components/ui/button"; | |
| 5 | + | import { TwoFactorCodeField } from "../components/one-time-code"; | |
| 6 | + | import { ErrorText, SubmitButton } from "../components/ui"; | |
| 8 | 7 | import { PENDING_COOKIE, TWO_FACTOR_COOKIE, cookie, readCookie } from "../lib/github"; | |
| 9 | 8 | import { githubSignIn } from "../lib/github.server"; | |
| 10 | 9 | import { page } from "../lib/meta"; | |
| 11 | − | import { otpInputProps } from "../lib/otp"; | |
| 12 | 10 | import { identity } from "../lib/services.server"; | |
| 13 | 11 | import { assertSameOrigin, clientOf, getViewer, nextPath, startSession } from "../lib/session.server"; | |
| 14 | 12 | ||
| ⋯ | |||
| 49 | 47 | } | |
| 50 | 48 | ||
| 51 | 49 | export default function LoginTwoFactor({ actionData }: Route.ComponentProps) { | |
| 52 | − | const [recovery, setRecovery] = useState(false); | |
| 53 | 50 | return ( | |
| 54 | 51 | <AuthCard | |
| 55 | 52 | title="Two-factor authentication" | |
| ⋯ | |||
| 61 | 58 | } | |
| 62 | 59 | > | |
| 63 | 60 | <Form id="two-factor-sign-in" method="post" className="space-y-4"> | |
| 64 | − | <Field label={recovery ? "Recovery code" : "Authentication code"} htmlFor="totp-code"> | |
| 65 | − | {/* Either kind is accepted whichever is chosen, so a pasted recovery code is never cut; the choice sets the keyboard. */} | |
| 66 | − | <Input {...otpInputProps({ id: "totp-code", recovery: true })} inputMode={recovery ? "text" : "numeric"} placeholder={recovery ? "k7m2q-9xw4d" : "123 456"} required autoFocus /> | |
| 67 | − | </Field> | |
| 68 | − | {/* A phone shows digits for an app's code; a recovery code needs letters. */} | |
| 69 | − | <Button type="button" variant="link" onClick={() => setRecovery(!recovery)}> | |
| 70 | − | {recovery ? "Use an authenticator code" : "Use a recovery code"} | |
| 71 | − | </Button> | |
| 61 | + | {/* Sent as soon as the sixth digit is in; a recovery code pasted into the boxes moves to its own field. */} | |
| 62 | + | <TwoFactorCodeField recovery autoFocus submit invalid={Boolean(actionData?.error)} /> | |
| 72 | 63 | <ErrorText>{actionData?.error}</ErrorText> | |
| 73 | 64 | <div className="pt-2 *:w-full"> | |
| 74 | 65 | <SubmitButton pending="Checking…">Verify</SubmitButton> | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/two-factor"; | |
| 6 | 6 | import { ConfirmItIsYou } from "../../components/emails-section"; | |
| 7 | + | import { TwoFactorCodeField } from "../../components/one-time-code"; | |
| 7 | 8 | import { QrCode } from "../../components/qr-code"; | |
| 8 | − | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 10 | 11 | import { Button } from "../../components/ui/button"; | |
| 11 | 12 | import { Card } from "../../components/ui/card"; | |
| 12 | 13 | import { Unavailable } from "../../components/boundary"; | |
| 13 | 14 | import { githubSignIn } from "../../lib/github.server"; | |
| 14 | 15 | import { page } from "../../lib/meta"; | |
| 15 | − | import { otpInputProps } from "../../lib/otp"; | |
| 16 | 16 | import { accounts } from "../../lib/services.server"; | |
| 17 | 17 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 18 | 18 | import { TWO_FACTOR_INTENTS, twoFactorAction } from "../../lib/two-factor.server"; | |
| ⋯ | |||
| 46 | 46 | /** A base32 secret in groups of four, as people type it. */ | |
| 47 | 47 | function grouped(secret: string): string { | |
| 48 | 48 | return secret.match(/.{1,4}/g)?.join(" ") ?? secret; | |
| 49 | − | } | |
| 50 | − | ||
| 51 | − | /** The six digits an app shows, typed. */ | |
| 52 | − | function CodeInput({ label = "Code from your app", recovery = false }: { label?: string; recovery?: boolean }) { | |
| 53 | − | return ( | |
| 54 | − | <Field label={label} htmlFor="totp-code" hint={recovery ? "Or a recovery code, such as k7m2q-9xw4d." : undefined}> | |
| 55 | − | <Input {...otpInputProps({ id: "totp-code", recovery })} required /> | |
| 56 | − | </Field> | |
| 57 | − | ); | |
| 58 | 49 | } | |
| 59 | 50 | ||
| 60 | 51 | /** Recovery codes, shown once, with a way to keep them. */ | |
| ⋯ | |||
| 152 | 143 | ? `You would lose access to ${status.required_by.join(", ")} until you turn it on again.` | |
| 153 | 144 | : "Your account is safer with it on."} | |
| 154 | 145 | </p> | |
| 155 | − | <Form method="post" className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 146 | + | <Form method="post" className="mt-4 flex flex-col items-start gap-3"> | |
| 156 | 147 | <input type="hidden" name="intent" value="two-factor-disable" /> | |
| 157 | − | <div className="grow sm:max-w-56"> | |
| 158 | − | <CodeInput recovery /> | |
| 159 | − | </div> | |
| 148 | + | <TwoFactorCodeField label="Code from your app" recovery /> | |
| 160 | 149 | <SubmitButton variant="destructive" match={{ intent: "two-factor-disable" }} pending="Turning off…"> | |
| 161 | 150 | Turn off | |
| 162 | 151 | </SubmitButton> | |
| ⋯ | |||
| 184 | 173 | </li> | |
| 185 | 174 | <li> | |
| 186 | 175 | <p className="text-muted">2. Enter the six-digit code your app shows.</p> | |
| 187 | − | <Form method="post" className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 176 | + | <Form method="post" className="mt-3 flex flex-col items-start gap-3"> | |
| 188 | 177 | <input type="hidden" name="intent" value="two-factor-enable" /> | |
| 189 | 178 | <input type="hidden" name="secret" value={setup.secret} /> | |
| 190 | 179 | <input type="hidden" name="uri" value={setup.uri} /> | |
| 191 | − | <div className="grow sm:max-w-56"> | |
| 192 | − | <CodeInput /> | |
| 193 | − | </div> | |
| 180 | + | <TwoFactorCodeField label="Code from your app" /> | |
| 194 | 181 | <SubmitButton match={{ intent: "two-factor-enable" }} pending="Checking…"> | |
| 195 | 182 | Turn on | |
| 196 | 183 | </SubmitButton> | |
| 30 | 30 | "cmdk": "^1.1.1", | |
| 31 | 31 | "hast-util-to-jsx-runtime": "^2.3.6", | |
| 32 | 32 | "html-url-attributes": "^3.0.1", | |
| 33 | + | "input-otp": "^1.4.2", | |
| 33 | 34 | "isbot": "^5.1.36", | |
| 34 | 35 | "katex": "^0.19.0", | |
| 35 | 36 | "lib0": "^0.2.119", |
Binary or large file; its contents are not shown.
Comments
Sign in to comment.