Skip to content

Commit

Permalink
feat: add email OTP support
Browse files Browse the repository at this point in the history
  • Loading branch information
dphilipson committed Nov 19, 2024
1 parent 4ba357b commit 132cf22
Show file tree
Hide file tree
Showing 24 changed files with 381 additions and 36 deletions.
14 changes: 9 additions & 5 deletions account-kit/core/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ export const createSigner = (params: ClientStoreConfig) => {
return signer;
};

const AUTHENTICATING_STATUSES: AlchemySignerStatus[] = [
AlchemySignerStatus.AUTHENTICATING_EMAIL,
AlchemySignerStatus.AUTHENTICATING_OAUTH,
AlchemySignerStatus.AUTHENTICATING_PASSKEY,
AlchemySignerStatus.AWAITING_EMAIL_AUTH,
AlchemySignerStatus.AWAITING_OTP_AUTH,
];

/**
* Converts the AlchemySigner's status to a more readable object
*
Expand All @@ -223,11 +231,7 @@ export const convertSignerStatusToState = (
status: alchemySignerStatus,
error,
isInitializing: alchemySignerStatus === AlchemySignerStatus.INITIALIZING,
isAuthenticating:
alchemySignerStatus === AlchemySignerStatus.AUTHENTICATING_EMAIL ||
alchemySignerStatus === AlchemySignerStatus.AUTHENTICATING_OAUTH ||
alchemySignerStatus === AlchemySignerStatus.AUTHENTICATING_PASSKEY ||
alchemySignerStatus === AlchemySignerStatus.AWAITING_EMAIL_AUTH,
isAuthenticating: AUTHENTICATING_STATUSES.includes(alchemySignerStatus),
isConnected: alchemySignerStatus === AlchemySignerStatus.CONNECTED,
isDisconnected: alchemySignerStatus === AlchemySignerStatus.DISCONNECTED,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Timeout } from "../../../../icons/timeout.js";
import { EOAWallets, type ConnectionErrorProps } from "./types.js";
import { WalletIcon } from "./icons/wallet-icon.js";
import { OAuthConnectionFailed } from "../../../../icons/oauth.js";
import { capitalize } from "../../../../utils.js";
import { assertNever, capitalize } from "../../../../utils.js";
import { disconnect } from "@account-kit/core";
import { useAlchemyAccountContext } from "../../../../context.js";

Expand Down Expand Up @@ -48,10 +48,17 @@ export const ConnectionError = ({
return `${ls.error.connection.oauthTitle} ${capitalize(
oauthProvider!
)}`;
case "otp":
return ls.error.connection.otpTitle;
case "wallet":
return ls.error.connection.walletTitle + (walletName ?? "wallet");
case "timeout":
return ls.error.connection.timedOutTitle;
default:
assertNever(
connectionType,
`Unknown connection type: ${connectionType}`
);
}
}, [EOAConnector, connectionType, oauthProvider, customErrorMessage]);

Expand All @@ -65,10 +72,17 @@ export const ConnectionError = ({
return ls.error.connection.passkeyBody;
case "oauth":
return ls.error.connection.oauthBody;
case "otp":
return ls.error.connection.otpBody;
case "wallet":
return ls.error.connection.walletBody;
case "timeout":
return ls.error.connection.timedOutBody;
default:
assertNever(
connectionType,
`Unknown connection type: ${connectionType}`
);
}
}, [connectionType, customErrorMessage]);

Expand All @@ -78,10 +92,18 @@ export const ConnectionError = ({
return <PasskeyConnectionFailed />;
case "oauth":
return <OAuthConnectionFailed provider={oauthProvider!} />; // TO DO: extend for BYO auth provider
case "otp":
// TODO: Placeholder icon, replace with design when ready.
return <PasskeyConnectionFailed />;
case "wallet":
return EOAConnector && <WalletIcon EOAConnector={EOAConnector} />;
case "timeout":
return <Timeout />;
default:
assertNever(
connectionType,
`Unknown connection type: ${connectionType}`
);
}
}, [connectionType, oauthProvider, EOAConnector]);
return (
Expand Down
2 changes: 1 addition & 1 deletion account-kit/react/src/components/auth/card/error/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export enum EOAWallets {
}

export type ConnectionErrorProps = {
connectionType: "passkey" | "oauth" | "wallet" | "timeout";
connectionType: "passkey" | "oauth" | "otp" | "wallet" | "timeout";
oauthProvider?: KnownAuthProvider; // TO DO: extend for BYO auth provider
EOAConnector?: Connector | EOAWallets.WALLET_CONNECT;
customErrorMessage?: CustomErrorMessage | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useAuthContext, type AuthStep } from "../../context.js";
import { Button } from "../../../button.js";

type EmailNotReceivedDisclaimerProps = {
authStep: Extract<AuthStep, { type: "email_verify" }>;
authStep: Extract<AuthStep, { type: "email_verify" | "otp_verify" }>;
};
export const EmailNotReceivedDisclaimer = ({
authStep,
Expand Down Expand Up @@ -40,6 +40,7 @@ export const EmailNotReceivedDisclaimer = ({
authenticate({
type: "email",
email: authStep.email,
mode: authStep.type === "email_verify" ? "magicLink" : "otp",
});
setEmailResent(true);
}}
Expand Down
3 changes: 3 additions & 0 deletions account-kit/react/src/components/auth/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const AuthCardContent = ({
const canGoBack = useMemo(() => {
return [
"email_verify",
"otp_verify",
"passkey_verify",
"passkey_create",
"pick_eoa",
Expand All @@ -85,9 +86,11 @@ export const AuthCardContent = ({
const onBack = useCallback(() => {
switch (authStep.type) {
case "email_verify":
case "otp_verify":
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
24 changes: 24 additions & 0 deletions account-kit/react/src/components/auth/card/loading/demoInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { InputHTMLAttributes } from "react";
import { Input } from "../../../input.js";

type DemoInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "ref">;

/*
* Input used for demoing new functionality. Should be replaced and deleted when
* designs are ready.
*/
export const DemoInput = (props: DemoInputProps) => {
return (
<div className="relative">
<Input className="pointer-events-none" />
<Input
{...props}
className="absolute border-none bg-transparent left-0 top-0"
style={{
transform: "rotate(10deg) scale(1.65)",
transformOrigin: "0 50%",
}}
/>
</div>
);
};
97 changes: 97 additions & 0 deletions account-kit/react/src/components/auth/card/loading/otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { useSignerStatus } from "../../../../hooks/useSignerStatus.js";
import { EmailIllustration } from "../../../../icons/illustrations/email.js";
import { Spinner } from "../../../../icons/spinner.js";
import { ls } from "../../../../strings.js";
import { useAuthContext } from "../../context.js";
import { DemoInput } from "./demoInput.js";
import { Button } from "../../../button.js";
import { useAuthenticate } from "../../../../hooks/useAuthenticate.js";
import { ConnectionError } from "../error/connection-error.js";

// eslint-disable-next-line jsdoc/require-jsdoc
export const LoadingOtp = () => {
const { authStep } = useAuthContext("otp_verify");
const [otpCode, setOtpCode] = useState("");
const { setAuthStep } = useAuthContext();

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

return (
<div className="flex flex-col gap-5 items-center">
<div className="flex flex-col items-center justify-center h-12 w-12">
<EmailIllustration height="48" width="48" className="animate-pulse" />
</div>

<h3 className="font-semibold text-lg">{ls.loadingEmail.title}</h3>
<DemoInput
value={otpCode}
onChange={(event) => setOtpCode(event.currentTarget.value)}
/>
<Button
onClick={() => {
authenticate({ type: "otp", otpCode });
}}
>
Enter code
</Button>
<p className="text-fg-secondary text-center text-sm">
We sent a code to
<br />
<span className="font-medium">{authStep.email}</span>
</p>
</div>
);
};

// eslint-disable-next-line jsdoc/require-jsdoc
export const CompletingOtpAuth = () => {
const { isConnected } = useSignerStatus();
const { authenticate } = useAuthenticate();
const { setAuthStep, authStep } = useAuthContext("otp_completing");

useEffect(() => {
if (isConnected && authStep.createPasskeyAfter) {
setAuthStep({ type: "passkey_create" });
} else if (isConnected) {
setAuthStep({ type: "complete" });
}
}, [authStep.createPasskeyAfter, isConnected, setAuthStep]);

if (authStep.error) {
return (
<ConnectionError
connectionType="otp"
handleTryAgain={() => {
const { email } = authStep;
setAuthStep({ type: "otp_verify", email });
authenticate({ type: "email", email, mode: "otp" });
}}
handleUseAnotherMethod={() => setAuthStep({ type: "initial" })}
/>
);
}

return (
<div className="flex flex-col gap-5 items-center">
<div className="flex flex-col items-center justify-center h-12 w-12">
<Spinner />
</div>

<p className="text-fg-secondary text-center text-sm">
{ls.completingOtp.body}
</p>
</div>
);
};
5 changes: 5 additions & 0 deletions account-kit/react/src/components/auth/card/steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AddPasskey } from "./add-passkey.js";
import { EoaConnectCard, EoaPickCard, WalletConnectCard } from "./eoa.js";
import { CompletingEmailAuth, LoadingEmail } from "./loading/email.js";
import { CompletingOAuth } from "./loading/oauth.js";
import { CompletingOtpAuth, LoadingOtp } from "./loading/otp.js";
import { LoadingPasskeyAuth } from "./loading/passkey.js";
import { MainAuthContent } from "./main.js";
import { PasskeyAdded } from "./passkey-added.js";
Expand All @@ -12,12 +13,16 @@ export const Step = () => {
switch (authStep.type) {
case "email_verify":
return <LoadingEmail />;
case "otp_verify":
return <LoadingOtp />;
case "passkey_verify":
return <LoadingPasskeyAuth />;
case "email_completing":
return <CompletingEmailAuth />;
case "oauth_completing":
return <CompletingOAuth />;
case "otp_completing":
return <CompletingOtpAuth />;
case "passkey_create":
return <AddPasskey />;
case "passkey_create_success":
Expand Down
7 changes: 7 additions & 0 deletions account-kit/react/src/components/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AuthType } from "./types";

export type AuthStep =
| { type: "email_verify"; email: string }
| { type: "otp_verify"; email: string }
| { type: "passkey_verify"; error?: Error }
| { type: "passkey_create"; error?: Error }
| { type: "passkey_create_success" }
Expand All @@ -16,6 +17,12 @@ export type AuthStep =
createPasskeyAfter?: boolean;
error?: Error;
}
| {
type: "otp_completing";
email: string;
createPasskeyAfter?: boolean;
error?: Error;
}
| { type: "initial"; error?: Error }
| { type: "complete" }
| { type: "eoa_connect"; connector: Connector; error?: Error }
Expand Down
3 changes: 2 additions & 1 deletion account-kit/react/src/components/auth/sections/EmailAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const EmailAuth = memo(
const { authenticateAsync, isPending } = useAuthenticate({
onMutate: async (params) => {
if ("email" in params) {
setAuthStep({ type: "email_verify", email: params.email });
setAuthStep({ type: "otp_verify", email: params.email });
}
},
onSuccess: () => {
Expand Down Expand Up @@ -57,6 +57,7 @@ export const EmailAuth = memo(
type: "email",
email,
redirectParams,
mode: "otp",
});
} catch (e) {
const error = e instanceof Error ? e : new Error("An Unknown error");
Expand Down
2 changes: 2 additions & 0 deletions account-kit/react/src/components/auth/sections/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const RenderFooterText = ({ authStep }: FooterProps) => {
case "initial":
return <RegistrationDisclaimer />;
case "email_verify":
case "otp_verify":
return <EmailNotReceivedDisclaimer authStep={authStep} />;
case "passkey_create":
case "wallet_connect":
Expand All @@ -22,6 +23,7 @@ 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
4 changes: 1 addition & 3 deletions account-kit/react/src/components/auth/sections/OAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ export const OAuth = memo(({ ...config }: Props) => {
></Button>
);
default:
assertNever("unhandled authProviderId passed into auth sections");
assertNever(config, "unhandled authProviderId passed into auth sections");
}

throw Error("unhandled authProviderId passed into auth sections");
});
7 changes: 6 additions & 1 deletion account-kit/react/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ const STRINGS = {
resent: "Done!",
},
completingEmail: {
body: "Your email verification is almost complete. Please wait a few seconds for this to screen to update.",
body: "Your email verification is almost complete. Please wait a few seconds for this screen to update.",
},
completingOtp: {
body: "Your code verification is almost complete. Please wait a few seconds for this screen to update.",
},
loadingPasskey: {
title: "Continue with passkey",
Expand All @@ -53,6 +56,8 @@ const STRINGS = {
"Passkey request timed out or canceled by the agent. You may have to use another method to register a passkey for your account.",
oauthTitle: "Couldn't connect to ",
oauthBody: "The connection failed or canceled",
otpTitle: "Connection failed",
otpBody: "The code could not be verified",
walletTitle: "Couldn't connect to ",
walletBody: "The wallet’s connection failed or canceled",
timedOutTitle: "Connection timed out",
Expand Down
4 changes: 2 additions & 2 deletions account-kit/react/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ export function capitalize(str: string) {
.join(" ");
}

export function assertNever(msg: string) {
throw new Error(msg);
export function assertNever(_: never, message: string): never {
throw new Error(message);
}
Loading

0 comments on commit 132cf22

Please sign in to comment.