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

The code boxes sit in the middle of signing in and confirming an email, a code is sent once, and nothing says "Please fill out this field". On the sign-in's second step and email confirmation the six boxes, the recovery link, the hint and the error are centred under the heading that already says what to enter; the label is for screen readers only there. Two-factor settings keep the labelled field, aligned left. A password manager that fills the code often sends the form itself too, and with the boxes sending it at the sixth digit the same code went twice: identity takes each authenticator code once (two_factor.last_step), so the second was refused and said "That code is not right" even when the code was right. While one sending is on its way, any other is now dropped by a submit listener on the form, which runs before React Router's, and the boxes never send a code again while one is out. The boxes are no longer `required`, which drew the browser's bubble over invisible input; an empty code is refused by the server as before. A refused code still clears the boxes and focuses them, and the red goes as soon as something new is typed. Checked: apps/web tsc -b and npm test (993), and the sign-in form in headless Chrome driven over CDP with an action that counts what reaches it: a filled code, the filler's own submit, the boxes' submit and one more while it was out reached the action once; after the refusal the boxes were empty, focused and red, and one typed digit took the red away. Not tried with a real password manager.

G1t-Session:
ses_01m50mr529evxafazhzv6vasds
syntaqxcommitted Parentdc6cb01Browse files
3 files+99−410/3 viewed
+86−34
99 * text field instead: a link switches between them, and a recovery code
1010 * pasted into the boxes switches by itself, the code carried across, so
1111 * 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.
1218 */
1319 import { REGEXP_ONLY_DIGITS } from "input-otp";
1420 import { type ClipboardEvent, useEffect, useRef, useState } from "react";
1723 import { Field, Input } from "./ui";
1824 import { Button } from "./ui/button";
1925 import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "./ui/input-otp";
26+import { cn } from "../lib/cn";
2027 import { codeDigits, looksLikeRecoveryCode, otpInputProps } from "../lib/otp";
2128
2229 /**
2330 * The six boxes. `submit` sends the form once the sixth digit is in.
24− * `onRecoveryCode`, when given, takes a recovery code pasted into them.
31+ * `invalid` (the code was refused) marks them until something new is
32+ * typed. `onRecoveryCode`, when given, takes a recovery code pasted in.
2533 */
2634 export function CodeSlots({
2735 id,
2937 autoFocus,
3038 submit = false,
3139 invalid = false,
40+ centered = false,
3241 describedBy,
3342 onRecoveryCode,
3443 }: {
3746 autoFocus?: boolean;
3847 submit?: boolean;
3948 invalid?: boolean;
49+ centered?: boolean;
4050 describedBy?: string;
4151 onRecoveryCode?: (code: string) => void;
4252 }) {
4353 const input = useRef<HTMLInputElement>(null);
4454 const [value, setValue] = useState("");
45− // A code that was sent and refused is cleared, ready for the next one.
55+ // Typed since the code was refused: the boxes stop saying it is wrong.
56+ const [edited, setEdited] = useState(false);
4657 const navigation = useNavigation();
47− const sent = useRef(false);
58+ const sending = useRef(false);
59+
60+ // When a sending comes back: a refused code is cleared, ready for the next one.
4861 useEffect(() => {
49− if (navigation.state !== "idle") {
50− sent.current = true;
51− return;
52− }
53− if (sent.current && invalid) {
62+ if (navigation.state !== "idle") return;
63+ if (sending.current && invalid) {
5464 setValue("");
65+ setEdited(false);
5566 input.current?.focus();
5667 }
57− sent.current = false;
68+ sending.current = false;
5869 }, [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+
5988 // Seen before the boxes' own paste handling, so a recovery code is not cut to digits first.
6089 const onPasteCapture = (event: ClipboardEvent) => {
6190 const pasted = event.clipboardData.getData("text");
74103 pattern={REGEXP_ONLY_DIGITS}
75104 pasteTransformer={codeDigits}
76105 value={value}
77− onChange={setValue}
106+ onChange={(next) => {
107+ setValue(next);
108+ setEdited(true);
109+ }}
78110 onComplete={() => {
79− if (submit) input.current?.form?.requestSubmit();
111+ if (submit && !sending.current) input.current?.form?.requestSubmit();
80112 }}
81113 autoComplete="one-time-code"
82114 inputMode="numeric"
83115 autoFocus={autoFocus}
84− required
85− aria-invalid={invalid || undefined}
116+ aria-invalid={(invalid && !edited) || undefined}
86117 aria-describedby={describedBy}
118+ containerClassName={cn(centered && "justify-center")}
87119 >
88120 <InputOTPGroup>
89121 <InputOTPSlot index={0} />
104136 /**
105137 * A two-factor code: the boxes for the code from an authenticator app,
106138 * or, with `recovery`, a way across to a recovery code's text field.
107− * Identity takes either kind whichever field it came from.
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.
108143 */
109144 export function TwoFactorCodeField({
110145 id = "totp-code",
114149 autoFocus,
115150 submit,
116151 invalid,
152+ centered = false,
117153 }: {
118154 id?: string;
119155 label?: string;
123159 autoFocus?: boolean;
124160 submit?: boolean;
125161 invalid?: boolean;
162+ centered?: boolean;
126163 }) {
127164 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+ );
128184 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>
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+ )}
149201 {recovery && (
150202 <Button type="button" variant="link" size="inline" className="text-sm" onClick={() => setUsingRecovery(usingRecovery ? false : { initial: "" })}>
151203 {usingRecovery ? "Use a code from your app" : "Use a recovery code"}
+9−5
129129 {address && (
130130 <Form method="post" className="mt-6 space-y-4">
131131 <input type="hidden" name="intent" value="confirm" />
132− <Field label="Confirmation code" htmlFor="email-verification-code" hint={`The code and the link work for ${MINUTES} minutes.`}>
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" />
135− </Field>
136− <div id="code-error">
132+ {/* The boxes sit in the middle, under the line that says what to enter; sent as soon as the sixth digit is in. */}
133+ <div>
134+ <label htmlFor="email-verification-code" className="sr-only">
135+ Confirmation code
136+ </label>
137+ <CodeSlots id="email-verification-code" autoFocus submit centered invalid={errorFor("confirm") != null} describedBy="code-error" />
138+ <p className="mt-2 text-center text-xs text-faint">The code and the link work for {MINUTES} minutes.</p>
139+ </div>
140+ <div id="code-error" className="text-center">
137141 <ErrorText>{errorFor("confirm")}</ErrorText>
138142 </div>
139143 <div className="*:w-full">
+4−2
5959 >
6060 <Form id="two-factor-sign-in" method="post" className="space-y-4">
6161 {/* 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)} />
63− <ErrorText>{actionData?.error}</ErrorText>
62+ <TwoFactorCodeField recovery autoFocus submit centered invalid={Boolean(actionData?.error)} />
63+ <div className="text-center">
64+ <ErrorText>{actionData?.error}</ErrorText>
65+ </div>
6466 <div className="pt-2 *:w-full">
6567 <SubmitButton pending="Checking…">Verify</SubmitButton>
6668 </div>

Comments

Sign in to comment.