Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parent8e3376fBrowse files
11 files+268−520/11 viewed
+14−0
8686 --animate-sheet-out-left: sheet-out-left 0.16s ease-in forwards;
8787 /* "Thinking…" before g1t's first words: lavender sweeping across (components/ai-elements/loader.tsx). */
8888 --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+ }
89103 }
90104
91105 @keyframes shimmer {
+156−0
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+}
+58−0
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+}
+7−2
7575
7676 test("the code field is a one-time code typed with a number pad", () => {
7777 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"/);
7984 assert.match(input, /autoComplete="one-time-code"/);
8085 assert.match(input, /inputMode="numeric"/);
81− assert.match(input, /pattern="\[0-9 -\]\*"/);
86+ assert.match(input, /pattern=\{REGEXP_ONLY_DIGITS\}/);
8287 // Either route: the page says the link in the email works too.
8388 assert.match(page, /link in the email/);
8489 });
+8−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { otpInputProps } from "./otp.ts";
4+import { codeDigits, looksLikeRecoveryCode, otpInputProps } from "./otp.ts";
55
66 test("one-time-code fields are findable by password managers and carry no ignore attributes", () => {
77 for (const recovery of [false, true]) {
3030 assert.equal(props.pattern, undefined);
3131 for (const code of ["k7m2q-9xw4d", "K7M2Q 9XW4D", "123456"]) assert.ok(code.length <= props.maxLength);
3232 });
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+});
+11−0
2323 placeholder: recovery ? "k7m2q-9xw4d" : "123 456",
2424 } as const;
2525 }
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+}
+3−17
55
66 import type { Route } from "./+types/confirm-email";
77 import { page } from "../lib/meta";
8+import { CodeSlots } from "../components/one-time-code";
89 import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
910 import { Button } from "../components/ui/button";
1011 import { Hint } from "../components/ui/hint";
129130 <Form method="post" className="mt-6 space-y-4">
130131 <input type="hidden" name="intent" value="confirm" />
131132 <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" />
149135 </Field>
150136 <div id="code-error">
151137 <ErrorText>{errorFor("confirm")}</ErrorText>
+4−13
1−import { useState } from "react";
21 import { Form, Link, redirect } from "react-router";
32
43 import type { Route } from "./+types/login-two-factor";
54 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";
87 import { PENDING_COOKIE, TWO_FACTOR_COOKIE, cookie, readCookie } from "../lib/github";
98 import { githubSignIn } from "../lib/github.server";
109 import { page } from "../lib/meta";
11−import { otpInputProps } from "../lib/otp";
1210 import { identity } from "../lib/services.server";
1311 import { assertSameOrigin, clientOf, getViewer, nextPath, startSession } from "../lib/session.server";
1412
4947 }
5048
5149 export default function LoginTwoFactor({ actionData }: Route.ComponentProps) {
52− const [recovery, setRecovery] = useState(false);
5350 return (
5451 <AuthCard
5552 title="Two-factor authentication"
6158 }
6259 >
6360 <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)} />
7263 <ErrorText>{actionData?.error}</ErrorText>
7364 <div className="pt-2 *:w-full">
7465 <SubmitButton pending="Checking…">Verify</SubmitButton>
+6−19
44
55 import type { Route } from "./+types/two-factor";
66 import { ConfirmItIsYou } from "../../components/emails-section";
7+import { TwoFactorCodeField } from "../../components/one-time-code";
78 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";
910 import { Badge } from "../../components/ui/badge";
1011 import { Button } from "../../components/ui/button";
1112 import { Card } from "../../components/ui/card";
1213 import { Unavailable } from "../../components/boundary";
1314 import { githubSignIn } from "../../lib/github.server";
1415 import { page } from "../../lib/meta";
15−import { otpInputProps } from "../../lib/otp";
1616 import { accounts } from "../../lib/services.server";
1717 import { assertSameOrigin, requireUser } from "../../lib/session.server";
1818 import { TWO_FACTOR_INTENTS, twoFactorAction } from "../../lib/two-factor.server";
4646 /** A base32 secret in groups of four, as people type it. */
4747 function grouped(secret: string): string {
4848 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− );
5849 }
5950
6051 /** Recovery codes, shown once, with a way to keep them. */
152143 ? `You would lose access to ${status.required_by.join(", ")} until you turn it on again.`
153144 : "Your account is safer with it on."}
154145 </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">
156147 <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 />
160149 <SubmitButton variant="destructive" match={{ intent: "two-factor-disable" }} pending="Turning off…">
161150 Turn off
162151 </SubmitButton>
184173 </li>
185174 <li>
186175 <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">
188177 <input type="hidden" name="intent" value="two-factor-enable" />
189178 <input type="hidden" name="secret" value={setup.secret} />
190179 <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" />
194181 <SubmitButton match={{ intent: "two-factor-enable" }} pending="Checking…">
195182 Turn on
196183 </SubmitButton>
+1−0
3030 "cmdk": "^1.1.1",
3131 "hast-util-to-jsx-runtime": "^2.3.6",
3232 "html-url-attributes": "^3.0.1",
33+ "input-otp": "^1.4.2",
3334 "isbot": "^5.1.36",
3435 "katex": "^0.19.0",
3536 "lib0": "^0.2.119",
+0−0

Binary or large file; its contents are not shown.

Comments

Sign in to comment.