Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: otp new styles and flow #1250

Merged
merged 12 commits into from
Jan 21, 2025
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useAuthenticate } from "../../../../hooks/useAuthenticate.js";
import { ls } from "../../../../strings.js";
import { useAuthContext, type AuthStep } from "../../context.js";
import {
AuthStepStatus,
useAuthContext,
type AuthStep,
} from "../../context.js";
import { Button } from "../../../button.js";

type EmailNotReceivedDisclaimerProps = {
Expand All @@ -18,6 +22,14 @@ export const EmailNotReceivedDisclaimer = ({
},
});

const isOTPVerifying = useMemo(() => {
return (
authStep.type === "otp_verify" &&
(authStep.status === AuthStepStatus.verifying ||
authStep.status === AuthStepStatus.success)
);
}, [authStep]);

useEffect(() => {
if (emailResent) {
// set the text back to "Resend" after 2 seconds
Expand All @@ -29,13 +41,20 @@ export const EmailNotReceivedDisclaimer = ({

return (
<div className="flex flex-row gap-2 justify-center mb-2">
<span className="text-fg-tertiary text-xs">
<span
className={`${
isOTPVerifying ? "text-fg-disabled" : "text-fg-tertiary"
} text-xs`}
>
{ls.loadingEmail.emailNotReceived}
</span>
<Button
variant="link"
className="text-xs font-normal underline"
disabled={emailResent}
className={`text-xs font-normal underline ${
isOTPVerifying ? "text-fg-disabled" : "text-btn-primary"
}`}
style={isOTPVerifying ? { opacity: 1 } : {}}
disabled={emailResent || isOTPVerifying}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency, can we use the tailwind class opacity-100 instead of a style prop here?

onClick={() => {
authenticate({
type: "email",
Expand Down
1 change: 0 additions & 1 deletion account-kit/react/src/components/auth/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export const AuthCardContent = ({
case "passkey_verify":
case "passkey_create":
case "oauth_completing":
case "otp_completing":
disconnect(config); // Terminate any inflight authentication
didGoBack.current = true;
setAuthStep({ type: "initial" });
Expand Down
70 changes: 26 additions & 44 deletions account-kit/react/src/components/auth/card/loading/otp.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { EmailIllustration } from "../../../../icons/illustrations/email.js";
import { ls } from "../../../../strings.js";
import {
Expand All @@ -8,54 +8,50 @@ import {
isOTPCodeType,
} from "../../../otp-input/otp-input.js";
import { Spinner } from "../../../../icons/spinner.js";
import { useAuthContext } from "../../context.js";
import { AuthStepStatus, useAuthContext } from "../../context.js";
import { useAuthenticate } from "../../../../hooks/useAuthenticate.js";
import { useSignerStatus } from "../../../../hooks/useSignerStatus.js";

export const LoadingOtp = () => {
const { isConnected } = useSignerStatus();
const { authStep, setAuthStep } = useAuthContext("otp_verify");
const { authStep } = useAuthContext("otp_verify");
const [otpCode, setOtpCode] = useState<OTPCodeType>(initialOTPValue);
const [errorText, setErrorText] = useState(
getUserErrorMessage(authStep.error)
);
const resetOTP = () => {
const [errorText, setErrorText] = useState(authStep.error?.message || "");
const [titleText, setTitleText] = useState(ls.loadingOtp.title);
const { setAuthStep } = useAuthContext();
const resetOTP = (errorText = "") => {
setOtpCode(initialOTPValue);
setErrorText("");
setErrorText(errorText);

if (errorText) {
setTitleText(ls.loadingOtp.title);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does resetting the OTP only reset the title if there is error text?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so we don't call that function in case there wasn't an error, in that case the text doesn't have to be updated, but since removing the conditional is not affecting the functionality I'll just do it.
Thanks

};
const { authenticate } = useAuthenticate({
onError: (error: any) => {
console.error(error);
const { email } = authStep;
setAuthStep({ type: "otp_verify", email, error });

setAuthStep({ ...authStep, error, status: null });
resetOTP(getUserErrorMessage(error));
},
onSuccess: () => {
if (isConnected) {
setAuthStep({ type: "complete" });
setAuthStep({ ...authStep, status: AuthStepStatus.success });
setTitleText(ls.loadingOtp.verified);
setTimeout(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks to me like we might be able to derive the title text from the auth step, so we don't need to maintain state for it. Is that right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need the state since the error text is not only updated by the auth step but also the input field which is a child component, the error can come from there too.

setAuthStep({ type: "complete" });
}, 3000);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's extract this delay into a top-level constant in this file.

},
});

useEffect(() => {
if (isOTPCodeType(otpCode)) {
setAuthStep({
type: "otp_completing",
email: authStep.email,
otp: otpCode.join(""),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otpCode]);

const setValue = (otpCode: OTPCodeType) => {
setOtpCode(otpCode);
if (isOTPCodeType(otpCode)) {
const otp = otpCode.join("");
setAuthStep({
type: "otp_completing",
email: authStep.email,
otp,
});

setAuthStep({ ...authStep, status: AuthStepStatus.verifying });
setTitleText(ls.loadingOtp.verifying);
authenticate({ type: "otp", otpCode: otp });
}
};
Expand All @@ -71,7 +67,7 @@ export const LoadingOtp = () => {
/>
</div>
<h3 className="text-fg-primary font-semibold text-lg mb-2">
{ls.loadingOtp.title}
{titleText}
</h3>
<p className="text-fg-secondary text-center text-sm mb-1">
{ls.loadingOtp.body}
Expand All @@ -80,32 +76,18 @@ export const LoadingOtp = () => {
{authStep.email}
</p>
<OTPInput
disabled={authStep.status === AuthStepStatus.verifying}
value={otpCode}
setValue={setValue}
setErrorText={setErrorText}
errorText={errorText}
handleReset={resetOTP}
isVerified={authStep.status === AuthStepStatus.success}
/>
</div>
);
};

export const CompletingOtp = () => {
return (
<div className="flex flex-col items-center justify-center ">
<div className="flex flex-col items-center justify-center h-12 w-12 mb-5">
<Spinner />
</div>
<h2 className="text-fg-primary font-semibold text-lg mb-2">
{ls.completingOtp.title}
</h2>
<p className="text-fg-secondary text-center text-sm">
{ls.completingOtp.body}
</p>
</div>
);
};

function getUserErrorMessage(error: Error | undefined): string {
if (!error) {
return "";
Expand Down
4 changes: 1 addition & 3 deletions account-kit/react/src/components/auth/card/steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CompletingOAuth } from "./loading/oauth.js";
import { LoadingPasskeyAuth } from "./loading/passkey.js";
import { MainAuthContent } from "./main.js";
import { PasskeyAdded } from "./passkey-added.js";
import { CompletingOtp, LoadingOtp } from "./loading/otp.js";
import { LoadingOtp } from "./loading/otp.js";

export const Step = () => {
const { authStep } = useAuthContext();
Expand All @@ -21,8 +21,6 @@ export const Step = () => {
return <CompletingEmailAuth />;
case "oauth_completing":
return <CompletingOAuth />;
case "otp_completing":
return <CompletingOtp />;
case "passkey_create":
return <AddPasskey />;
case "passkey_create_success":
Expand Down
19 changes: 12 additions & 7 deletions account-kit/react/src/components/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ import type { Connector } from "@wagmi/core";
import { createContext, useContext } from "react";
import type { AuthType } from "./types";

export enum AuthStepStatus {
success = "success",
error = "error",
verifying = "verifying",
}

export type AuthStep =
| { type: "email_verify"; email: string }
| { type: "otp_verify"; email: string; error?: Error }
| {
type: "otp_verify";
email: string;
error?: Error;
status?: AuthStepStatus | null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The | null is a bit out of place compared to the rest of these types. Can we have it as just status?: AuthStepStatus?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I will replace the null with a base state, good feedback, thanks.

}
| { type: "passkey_verify"; error?: Error }
| { type: "passkey_create"; error?: Error }
| { type: "passkey_create_success" }
Expand All @@ -16,12 +27,6 @@ export type AuthStep =
config: Extract<AuthType, { type: "social" }>;
error?: Error;
}
| {
type: "otp_completing";
email: string;
otp: string;
error?: Error;
}
| { type: "initial"; error?: Error }
| { type: "complete" }
| { type: "eoa_connect"; connector: Connector; error?: Error }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const RenderFooterText = ({ authStep }: FooterProps) => {
case "oauth_completing":
return <OAuthContactSupport />;
case "email_completing":
case "otp_completing":
case "passkey_create_success":
case "eoa_connect":
case "pick_eoa":
Expand Down
22 changes: 16 additions & 6 deletions account-kit/react/src/components/otp-input/otp-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ const OTP_LENGTH = 6;
type OTPInputProps = {
errorText?: string;
value: OTPCodeType;
setValue: (otp: OTPCodeType) => void;
setErrorText: (error: string) => void;
setValue: (otpCode: OTPCodeType) => void;
setErrorText: React.Dispatch<React.SetStateAction<string>>;
disabled?: boolean;
handleReset: () => void;
className?: string;
isVerified?: boolean;
};

export const isOTPCodeType = (arg: string[]): arg is OTPCodeType => {
Expand All @@ -30,6 +31,7 @@ export const OTPInput: React.FC<OTPInputProps> = ({
setErrorText,
handleReset,
className,
isVerified,
}) => {
const [autoComplete, setAutoComplete] = useState<string>("");
const [activeElement, setActiveElement] = useState<number | null>(0);
Expand Down Expand Up @@ -140,9 +142,17 @@ export const OTPInput: React.FC<OTPInputProps> = ({
<div className="flex gap-2.5">
{initialOTPValue.map((_, i) => (
<input
className={`border w-8 bg-bg-surface-default h-10 text-fg-primary rounded text-center focus:outline-active ${
!!errorText && "border-fg-critical"
}`}
className={`
border w-8 h-10 rounded text-center
focus:outline-none focus:border-active
${!disabled && "bg-bg-surface-default text-fg-primary"}
${!!errorText && "border-fg-critical"}
${isVerified && "border-fg-success"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is maybe pretty nitpicky, but this can end up adding classes with names like "null", "undefined", or "false", which due to the global nature of CSS could maybe conceivably be classes defined in the surrounding app and therefore inadvertently add styling to this element. I know it's pretty unlikely someone would choose those classnames, but it would still be more correct to do these with ternaries, e.g.

${isVerified ? "border-fg-success" : ""}

If writing that all the time is too burdensome, we can make ourselves a helper similar to clsx.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No nitpicky at all, this is great feedback, thanks!

${
disabled &&
"border-fg-disabled bg-bg-surface-inset text-fg-disabled"
}
`}
ref={(el) => (refs.current[i] = el)}
tabIndex={i + 1}
type="text"
Expand All @@ -158,7 +168,7 @@ export const OTPInput: React.FC<OTPInputProps> = ({
onInput={focusNextElement}
onKeyDown={handleKeydown}
key={i}
disabled={disabled}
disabled={disabled || isVerified}
value={value[i]}
aria-invalid={!!errorText}
/>
Expand Down
2 changes: 2 additions & 0 deletions account-kit/react/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const STRINGS = {
body: "We sent a verification code to",
notReceived: "Didn't receive code?",
resend: "Resend",
verifying: "Verifying...",
verified: "Verified!",
},
completingEmail: {
body: "Completing login. Please wait a few seconds for this to screen to update.",
Expand Down
1 change: 1 addition & 0 deletions account-kit/react/src/tailwind/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function createDefaultTheme(): AccountKitTheme {
"fg-disabled": createColorSet("#CBD5E1", "#475569"),
"fg-accent-brand": createColorSet("#E82594", "#FF66CC"),
"fg-critical": createColorSet("#B91C1C", "#F87171"),
"fg-success": createColorSet("#16A34A", "#86EFAC"),

// surface colors
"bg-surface-default": createColorSet("#fff", "#020617"),
Expand Down
1 change: 1 addition & 0 deletions account-kit/react/src/tailwind/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface AccountKitTheme {
"fg-disabled": ColorVariantRecord;
"fg-accent-brand": ColorVariantRecord;
"fg-critical": ColorVariantRecord;
"fg-success": ColorVariantRecord;

// surface colors
"bg-surface-default": ColorVariantRecord;
Expand Down
4 changes: 4 additions & 0 deletions account-kit/react/src/tailwind/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ describe("tailwind utils test", () => {
"dark": "#E2E8F0",
"light": "#475569",
},
"fg-success": {
"dark": "#86EFAC",
"light": "#16A34A",
},
"fg-tertiary": {
"dark": "#94A3B8",
"light": "#94A3B8",
Expand Down
Loading