Skip to content

Commit 480142a

Browse files
authored
[SID-1421][SID-1529] Improve OTP error & resend handling (#219)
* Display error on incorrect OTP code submission * Create <Delayed> component in primitives * Handle resend * Add changesets * Improve retry loading state * Fix subscription useEffect cleanup & dependencies * Remove margin from component, add gap to layout * Document <Delayed> component * Remove obsolete condition * Remove unnecessary `retry` state * Apply feedback * Add animation, increase resend delay
1 parent 79f9e00 commit 480142a

8 files changed

Lines changed: 141 additions & 21 deletions

File tree

.changeset/tidy-icons-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@slashid/react-primitives": minor
3+
---
4+
5+
Create <Delayed> component

.changeset/wise-actors-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@slashid/react": patch
3+
---
4+
5+
Improve handling of OTP error & resend
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useState, useEffect, type ReactNode } from "react";
2+
3+
type Props = {
4+
/** Period of time, after which the component will render its children */
5+
delayMs: number;
6+
children: ReactNode;
7+
/** Optional fallback component rendered initially, replaced by children after the delay */
8+
fallback?: ReactNode;
9+
/** Optional CSS class name for the wrapper */
10+
className?: string;
11+
};
12+
13+
/**
14+
* Utility component used to render its children after specified period of time
15+
*/
16+
export function Delayed({ delayMs, children, fallback, className }: Props) {
17+
const [render, setRender] = useState(false);
18+
19+
useEffect(() => {
20+
const timeout = setTimeout(() => {
21+
setRender(true);
22+
}, delayMs);
23+
24+
return () => clearTimeout(timeout);
25+
}, [delayMs]);
26+
27+
return (
28+
<div className={className}>{render ? children : fallback ?? null}</div>
29+
);
30+
}

packages/react-primitives/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { TextContext, TextProvider } from "./components/text/text-context";
3030
import { isBrowser } from "./browser/is-browser";
3131
import { MemoryStorage } from "./browser/memory-storage";
3232
import { CookieStorage } from "./browser/cookie-storage";
33+
import { Delayed } from "./components/delayed";
3334

3435
// theming
3536
export * from "./theme/theme.css";
@@ -58,6 +59,7 @@ export {
5859
Tabs,
5960
Teleport,
6061
Text,
62+
Delayed,
6163
};
6264

6365
// context

packages/react/src/components/form/authenticating/authenticating.css.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { style } from "@vanilla-extract/css";
1+
import { keyframes, style } from "@vanilla-extract/css";
22

33
export const retryPrompt = style({
44
display: "flex",
@@ -23,3 +23,18 @@ export const passwordRecoveryPrompt = style({
2323
alignItems: "baseline",
2424
marginTop: "8px",
2525
});
26+
27+
export const formInner = style({
28+
display: "flex",
29+
flexDirection: "column",
30+
gap: "8px",
31+
});
32+
33+
const fadeIn = keyframes({
34+
"0%": { opacity: "0" },
35+
"100%": { opacity: "1" },
36+
});
37+
38+
export const wrapper = style({
39+
animation: `${fadeIn} 0.3s`,
40+
});

packages/react/src/components/form/authenticating/messages.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { Factor } from "@slashid/slashid";
22
import { TextConfigKey } from "../../text/constants";
33

4+
type AuthenticatingMessageOptions = {
5+
isSubmitting: boolean;
6+
hasRetried: boolean;
7+
};
8+
49
// TODO add case for password
510
export function getAuthenticatingMessage(
611
factor: Factor,
7-
isSubmitting = false
12+
{ isSubmitting, hasRetried }: AuthenticatingMessageOptions = {
13+
isSubmitting: false,
14+
hasRetried: false,
15+
}
816
): { title: TextConfigKey; message: TextConfigKey } {
917
switch (factor.method) {
1018
case "oidc":
@@ -24,6 +32,12 @@ export function getAuthenticatingMessage(
2432
title: "authenticating.title.smsLink",
2533
};
2634
case "otp_via_sms": {
35+
if (isSubmitting && hasRetried) {
36+
return {
37+
message: "authenticating.retry.message.smsOtp",
38+
title: "authenticating.retry.title.smsOtp",
39+
};
40+
}
2741
if (isSubmitting) {
2842
return {
2943
message: "authenticating.submitting.message.smsOtp",
@@ -36,6 +50,12 @@ export function getAuthenticatingMessage(
3650
};
3751
}
3852
case "otp_via_email":
53+
if (isSubmitting && hasRetried) {
54+
return {
55+
message: "authenticating.retry.message.emailOtp",
56+
title: "authenticating.retry.title.emailOtp",
57+
};
58+
}
3959
if (isSubmitting) {
4060
return {
4161
message: "authenticating.submitting.message.emailOtp",

packages/react/src/components/form/authenticating/otp.tsx

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "react";
88

99
import { Factor } from "@slashid/slashid";
10-
import { OtpInput } from "@slashid/react-primitives";
10+
import { OtpInput, Delayed } from "@slashid/react-primitives";
1111

1212
import { useConfiguration } from "../../../hooks/use-configuration";
1313
import { useForm } from "../../../hooks/use-form";
@@ -35,24 +35,28 @@ const FactorIcon = ({ factor }: { factor: Factor }) => {
3535
return <Loader />;
3636
};
3737

38+
const BASE_RETRY_DELAY = 2000;
39+
3840
/**
3941
* Presents the user with a form to enter an OTP code.
4042
* Handles retries in case of submitting an incorrect OTP code.
4143
*/
4244
export const OTPState = ({ flowState }: Props) => {
4345
const { text } = useConfiguration();
4446
const { sid } = useSlashID();
45-
const { values, registerField, registerSubmit } = useForm();
47+
const { values, registerField, registerSubmit, setError, clearError } =
48+
useForm();
4649
const [formState, setFormState] = useState<
4750
"initial" | "input" | "submitting"
4851
>("initial");
4952
const submitInputRef = useRef<HTMLInputElement>(null);
5053

5154
const factor = flowState.context.config.factor;
52-
const { title, message } = getAuthenticatingMessage(
53-
factor,
54-
formState === "submitting"
55-
);
55+
const hasRetried = flowState.context.attempt > 1;
56+
const { title, message } = getAuthenticatingMessage(factor, {
57+
isSubmitting: formState === "submitting",
58+
hasRetried,
59+
});
5660

5761
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
5862
(e) => {
@@ -64,6 +68,18 @@ export const OTPState = ({ flowState }: Props) => {
6468
[sid, values]
6569
);
6670

71+
useEffect(() => {
72+
const handler = () => {
73+
setError("otp", {
74+
message: text["authenticating.otpInput.submit.error"],
75+
});
76+
values["otp"] = "";
77+
};
78+
sid?.subscribe("otpIncorrectCodeSubmitted", handler);
79+
80+
return () => sid?.unsubscribe("otpIncorrectCodeSubmitted", handler);
81+
}, [setError, sid, text, values]);
82+
6783
const handleChange = useCallback(
6884
(otp: string) => {
6985
const onChange = registerField("otp", {
@@ -79,11 +95,18 @@ export const OTPState = ({ flowState }: Props) => {
7995
},
8096
};
8197

98+
clearError("otp");
8299
onChange(event as never);
83100
},
84-
[registerField, text]
101+
[clearError, registerField, text]
85102
);
86103

104+
const handleRetry = () => {
105+
flowState.retry();
106+
clearError("otp");
107+
setFormState("submitting");
108+
};
109+
87110
useEffect(() => {
88111
if (isValidOTPCode(values["otp"])) {
89112
// Automatically submit the form when the OTP code is valid
@@ -109,21 +132,36 @@ export const OTPState = ({ flowState }: Props) => {
109132
onSubmit={registerSubmit(handleSubmit)}
110133
className={styles.otpForm}
111134
>
112-
<OtpInput
113-
shouldAutoFocus
114-
inputType="number"
115-
value={values["otp"] ?? ""}
116-
onChange={handleChange}
117-
numInputs={OTP_CODE_LENGTH}
118-
/>
119-
<input hidden type="submit" ref={submitInputRef} />
120-
<ErrorMessage name="otp" />
135+
<div className={styles.formInner}>
136+
<OtpInput
137+
shouldAutoFocus
138+
inputType="number"
139+
value={values["otp"] ?? ""}
140+
onChange={handleChange}
141+
numInputs={OTP_CODE_LENGTH}
142+
/>
143+
<input hidden type="submit" ref={submitInputRef} />
144+
<ErrorMessage name="otp" />
145+
</div>
121146
</form>
122147
)}
123148
{formState === "submitting" ? (
124-
<Loader />
125-
) : (
126-
<RetryPrompt onRetry={() => flowState.retry()} />
149+
hasRetried ? (
150+
<EmailIcon />
151+
) : (
152+
<Loader />
153+
)
154+
) : null}
155+
{formState === "input" && (
156+
// fallback to prevent layout shift
157+
<Delayed
158+
delayMs={BASE_RETRY_DELAY * flowState.context.attempt}
159+
fallback={<div style={{ height: 16 }} />}
160+
>
161+
<div className={styles.wrapper}>
162+
<RetryPrompt onRetry={handleRetry} />
163+
</div>
164+
</Delayed>
127165
)}
128166
</>
129167
);

packages/react/src/components/text/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,21 @@ export const TEXT = {
8080
"authenticating.title.emailOtp": "Check your email",
8181
"authenticating.submitting.message.emailOtp": "We are verifying the code.",
8282
"authenticating.submitting.title.emailOtp": "Please wait",
83+
"authenticating.retry.message.emailOtp": "We are resending the OTP code...",
84+
"authenticating.retry.title.emailOtp": "Please wait",
8385
"authenticating.message.smsOtp":
8486
"We have sent you a code via text. Please insert it here.",
8587
"authenticating.title.smsOtp": "Check your phone",
8688
"authenticating.submitting.message.smsOtp": "We are verifying the code.",
8789
"authenticating.submitting.title.smsOtp": "Please wait",
90+
"authenticating.retry.message.smsOtp": "We are resending the OTP code...",
91+
"authenticating.retry.title.smsOtp": "Please wait",
8892
"authenticating.message.oidc":
8993
"Please follow the instructions in the login screen from your SSO provider.",
9094
"authenticating.title.oidc": "Sign in with ",
9195
"authenticating.otpInput": "OTP",
9296
"authenticating.otpInput.submit": "Submit",
97+
"authenticating.otpInput.submit.error": "Please enter a valid code",
9398
"success.title": "You are now authenticated!",
9499
"success.subtitle": "You can now close this page.",
95100
"error.title": "Something went wrong...",

0 commit comments

Comments
 (0)