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.

208 linesCodeBlameRaw
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 * A form with `submit` is sent once, as soon as the sixth digit is in. A
14 * password manager that fills the code often sends the form itself as
15 * well; identity takes each authenticator code once, so a second sending
16 * of the same code would be refused and say the code is wrong. While one
17 * sending is on its way, any other is dropped.
18 */
19import { REGEXP_ONLY_DIGITS } from "input-otp";
20import { type ClipboardEvent, useEffect, useRef, useState } from "react";
21import { useNavigation } from "react-router";
22
23import { Field, Input } from "./ui";
24import { Button } from "./ui/button";
25import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "./ui/input-otp";
26import { cn } from "../lib/cn";
27import { codeDigits, looksLikeRecoveryCode, otpInputProps } from "../lib/otp";
28
29/**
30 * The six boxes. `submit` sends the form once the sixth digit is in.
31 * `invalid` (the code was refused) marks them until something new is
32 * typed. `onRecoveryCode`, when given, takes a recovery code pasted in.
33 */
34export function CodeSlots({
35 id,
36 name = "code",
37 autoFocus,
38 submit = false,
39 invalid = false,
40 centered = false,
41 describedBy,
42 onRecoveryCode,
43}: {
44 id: string;
45 name?: string;
46 autoFocus?: boolean;
47 submit?: boolean;
48 invalid?: boolean;
49 centered?: boolean;
50 describedBy?: string;
51 onRecoveryCode?: (code: string) => void;
52}) {
53 const input = useRef<HTMLInputElement>(null);
54 const [value, setValue] = useState("");
55 // Typed since the code was refused: the boxes stop saying it is wrong.
56 const [edited, setEdited] = useState(false);
57 const navigation = useNavigation();
58 const sending = useRef(false);
59
60 // When a sending comes back: a refused code is cleared, ready for the next one.
61 useEffect(() => {
62 if (navigation.state !== "idle") return;
63 if (sending.current && invalid) {
64 setValue("");
65 setEdited(false);
66 input.current?.focus();
67 }
68 sending.current = false;
69 }, [navigation.state, invalid]);
70
71 // One sending at a time: the first (ours or a password manager's) goes, any other is dropped.
72 // A listener on the form itself runs before React Router's, which listens at the root.
73 useEffect(() => {
74 const form = input.current?.form;
75 if (!form) return;
76 const once = (event: SubmitEvent) => {
77 if (sending.current) {
78 event.preventDefault();
79 event.stopImmediatePropagation();
80 return;
81 }
82 sending.current = true;
83 };
84 form.addEventListener("submit", once);
85 return () => form.removeEventListener("submit", once);
86 }, []);
87
88 // Seen before the boxes' own paste handling, so a recovery code is not cut to digits first.
89 const onPasteCapture = (event: ClipboardEvent) => {
90 const pasted = event.clipboardData.getData("text");
91 if (!onRecoveryCode || !looksLikeRecoveryCode(pasted)) return;
92 event.preventDefault();
93 event.stopPropagation();
94 onRecoveryCode(pasted.trim());
95 };
96 return (
97 <div onPasteCapture={onPasteCapture}>
98 <InputOTP
99 ref={input}
100 id={id}
101 name={name}
102 maxLength={6}
103 pattern={REGEXP_ONLY_DIGITS}
104 pasteTransformer={codeDigits}
105 value={value}
106 onChange={(next) => {
107 setValue(next);
108 setEdited(true);
109 }}
110 onComplete={() => {
111 if (submit && !sending.current) input.current?.form?.requestSubmit();
112 }}
113 autoComplete="one-time-code"
114 inputMode="numeric"
115 autoFocus={autoFocus}
116 aria-invalid={(invalid && !edited) || undefined}
117 aria-describedby={describedBy}
118 containerClassName={cn(centered && "justify-center")}
119 >
120 <InputOTPGroup>
121 <InputOTPSlot index={0} />
122 <InputOTPSlot index={1} />
123 <InputOTPSlot index={2} />
124 </InputOTPGroup>
125 <InputOTPSeparator />
126 <InputOTPGroup>
127 <InputOTPSlot index={3} />
128 <InputOTPSlot index={4} />
129 <InputOTPSlot index={5} />
130 </InputOTPGroup>
131 </InputOTP>
132 </div>
133 );
134}
135
136/**
137 * A two-factor code: the boxes for the code from an authenticator app,
138 * or, with `recovery`, a way across to a recovery code's text field.
139 * Identity takes either kind whichever field it came from. `centered`
140 * is for a page whose heading already says what to enter (signing in):
141 * the label is for screen readers, and the boxes and the link sit in the
142 * middle.
143 */
144export function TwoFactorCodeField({
145 id = "totp-code",
146 label = "Authentication code",
147 hint,
148 recovery = false,
149 autoFocus,
150 submit,
151 invalid,
152 centered = false,
153}: {
154 id?: string;
155 label?: string;
156 hint?: string;
157 /** Whether a recovery code is taken here too. */
158 recovery?: boolean;
159 autoFocus?: boolean;
160 submit?: boolean;
161 invalid?: boolean;
162 centered?: boolean;
163}) {
164 const [usingRecovery, setUsingRecovery] = useState<false | { initial: string }>(false);
165 const shownLabel = usingRecovery ? "Recovery code" : label;
166 const field = usingRecovery ? (
167 <Input
168 {...otpInputProps({ id, recovery: true })}
169 defaultValue={usingRecovery.initial}
170 aria-invalid={invalid || undefined}
171 autoFocus={autoFocus || usingRecovery.initial !== ""}
172 className={cn(centered && "text-center")}
173 />
174 ) : (
175 <CodeSlots
176 id={id}
177 autoFocus={autoFocus}
178 submit={submit}
179 invalid={invalid}
180 centered={centered}
181 onRecoveryCode={recovery ? (code) => setUsingRecovery({ initial: code }) : undefined}
182 />
183 );
184 return (
185 <div className={cn("flex flex-col gap-3", centered ? "items-center" : "items-start")}>
186 {centered ? (
187 <div className="w-full">
188 <label htmlFor={id} className="sr-only">
189 {shownLabel}
190 </label>
191 {field}
192 {hint && <p className="mt-2 text-center text-xs text-faint">{hint}</p>}
193 </div>
194 ) : (
195 <div className="w-full">
196 <Field label={shownLabel} htmlFor={id} hint={hint}>
197 {field}
198 </Field>
199 </div>
200 )}
201 {recovery && (
202 <Button type="button" variant="link" size="inline" className="text-sm" onClick={() => setUsingRecovery(usingRecovery ? false : { initial: "" })}>
203 {usingRecovery ? "Use a code from your app" : "Use a recovery code"}
204 </Button>
205 )}
206 </div>
207 );
208}