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.

156 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 */
13import { REGEXP_ONLY_DIGITS } from "input-otp";
14import { type ClipboardEvent, useEffect, useRef, useState } from "react";
15import { useNavigation } from "react-router";
16
17import { Field, Input } from "./ui";
18import { Button } from "./ui/button";
19import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "./ui/input-otp";
20import { 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 */
26export 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 */
109export 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}