Skip to content

Commit

Permalink
feat: add support for adding a passkey on signup
Browse files Browse the repository at this point in the history
  • Loading branch information
moldy530 committed May 10, 2024
1 parent b56fe8d commit ab6bad3
Show file tree
Hide file tree
Showing 18 changed files with 244 additions and 60 deletions.
3 changes: 2 additions & 1 deletion examples/ui-demo/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const queryClient = new QueryClient();
// we should export a default uiConfig which has our recommended config
const uiConfig: AlchemyAccountsProviderProps["uiConfig"] = {
auth: {
sections: [[{type: "email"}], [{type: "passkey"}]]
sections: [[{type: "email"}], [{type: "passkey"}]],
addPasskeyOnSignup: true,
}
};

Expand Down
49 changes: 49 additions & 0 deletions packages/alchemy/src/react/components/auth/card/add-passkey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useAddPasskey } from "../../../hooks/useAddPasskey.js";
import { PasskeyIcon } from "../../../icons/passkey.js";
import { Button } from "../../button.js";
import { ErrorContainer } from "../../error.js";
import { PoweredBy } from "../../poweredby.js";
import { useAuthContext } from "../context.js";

// eslint-disable-next-line jsdoc/require-jsdoc
export const AddPasskey = () => {
const { setAuthStep } = useAuthContext();
const { addPasskey, isAddingPasskey, error } = useAddPasskey({
onSuccess: () => {
setAuthStep({ type: "complete" });
},
});

return (
<div className="flex flex-col gap-5 items-center">
<span className="text-lg text-fg-primary font-semibold">
Add a passkey?
</span>
<div className="flex flex-col items-center justify-center border-fg-accent-brand bg-bg-surface-inset rounded-[100%] w-[56px] h-[56px] border">
<PasskeyIcon />
</div>
<p className="text-fg-secondary text-center font-normal text-sm">
Passkeys allow for a simple and secure user experience. Login in and
sign transactions in seconds
</p>
{error && <ErrorContainer error={error} />}
<Button
type="primary"
className="w-full"
onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
Continue
</Button>
<Button
type="secondary"
className="w-full"
onClick={() => setAuthStep({ type: "complete" })}
disabled={isAddingPasskey}
>
Skip
</Button>
<PoweredBy />
</div>
);
};
29 changes: 17 additions & 12 deletions packages/alchemy/src/react/components/auth/card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useState, type ReactNode } from "react";
import { useLayoutEffect, useState, type ReactNode } from "react";
import { useSignerStatus } from "../../../hooks/useSignerStatus.js";
import { IS_SIGNUP_QP } from "../../constants.js";
import { AuthModalContext, type AuthStep } from "../context.js";
import type { AuthType } from "../types.js";
import { LoadingAuth } from "./loading/index.js";
import { MainAuthContent } from "./main.js";
import { Step } from "./steps.js";

export type AuthCardProps = {
header?: ReactNode;
Expand All @@ -23,14 +23,23 @@ export type AuthCardProps = {
* @returns a react component containing the AuthCard
*/
export const AuthCard = (props: AuthCardProps) => {
const [authStep, setAuthStep] = useState<AuthStep>({ type: "initial" });
const { isAuthenticating } = useSignerStatus();
const { status, isAuthenticating } = useSignerStatus();
const [authStep, setAuthStep] = useState<AuthStep>({
type: isAuthenticating ? "email_completing" : "initial",
});

useEffect(() => {
useLayoutEffect(() => {
if (authStep.type === "complete") {
props.onAuthSuccess?.();
} else if (isAuthenticating && authStep.type === "initial") {
const urlParams = new URLSearchParams(window.location.search);

setAuthStep({
type: "email_completing",
createPasskeyAfter: urlParams.get(IS_SIGNUP_QP) === "true",
});
}
}, [authStep, props]);
}, [authStep, status, props, isAuthenticating]);

return (
<AuthModalContext.Provider
Expand All @@ -40,11 +49,7 @@ export const AuthCard = (props: AuthCardProps) => {
}}
>
<div className="modal-box flex flex-col items-center gap-5">
{isAuthenticating ? (
<LoadingAuth context={authStep} />
) : (
<MainAuthContent {...props} />
)}
<Step {...props} />
</div>
</AuthModalContext.Provider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { useAuthenticate } from "../../../../hooks/useAuthenticate.js";
import { useSignerStatus } from "../../../../hooks/useSignerStatus.js";
import { HourglassIcon } from "../../../../icons/hourglass.js";
import { MailIcon } from "../../../../icons/mail.js";
import { Button } from "../../../button.js";
import { PoweredBy } from "../../../poweredby.js";
Expand All @@ -16,7 +18,6 @@ export const LoadingEmail = ({ context }: LoadingEmailProps) => {
const { setAuthStep } = useAuthContext();
const { authenticate } = useAuthenticate({
onSuccess: () => {
// TODO: we will need to check if the dev has configured the app to allow for passkey as secondary
setAuthStep({ type: "complete" });
},
});
Expand Down Expand Up @@ -65,3 +66,37 @@ export const LoadingEmail = ({ context }: LoadingEmailProps) => {
</div>
);
};

interface CompletingEmailAuthProps {
context: Extract<AuthStep, { type: "email_completing" }>;
}

// eslint-disable-next-line jsdoc/require-jsdoc
export const CompletingEmailAuth = ({ context }: CompletingEmailAuthProps) => {
const { isConnected } = useSignerStatus();
const { setAuthStep } = useAuthContext();

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

return (
<div className="flex flex-col gap-5 items-center">
<span className="text-lg text-fg-primary font-semibold">
Completing authorization
</span>
<div className="flex flex-col items-center justify-center border-fg-accent-brand bg-bg-surface-inset rounded-[100%] w-[56px] h-[56px] border">
<HourglassIcon />
</div>
<p className="text-fg-secondary text-center font-normal text-sm">
Your email verification is almost complete. Please wait a few seconds
for this screen to update.
</p>
<PoweredBy />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AuthStep } from "../../context";
import { LoadingEmail } from "./email.js";
import { CompletingEmailAuth, LoadingEmail } from "./email.js";
import { LoadingPasskeyAuth } from "./passkey.js";

type LoadingAuthProps = {
Expand All @@ -13,7 +13,11 @@ export const LoadingAuth = ({ context }: LoadingAuthProps) => {
return <LoadingEmail context={context} />;
case "passkey_verify":
return <LoadingPasskeyAuth context={context} />;
default:
return <div>Logging you in...</div>;
case "email_completing":
return <CompletingEmailAuth context={context} />;
default: {
console.warn("Unhandled loading state! rendering empty state", context);
return null;
}
}
};
23 changes: 23 additions & 0 deletions packages/alchemy/src/react/components/auth/card/steps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useAuthContext } from "../context.js";
import { AddPasskey } from "./add-passkey.js";
import type { AuthCardProps } from "./index.js";
import { LoadingAuth } from "./loading/index.js";
import { MainAuthContent } from "./main.js";

// eslint-disable-next-line jsdoc/require-jsdoc
export const Step = (props: AuthCardProps) => {
const { authStep } = useAuthContext();

switch (authStep.type) {
case "email_verify":
case "passkey_verify":
case "email_completing":
return <LoadingAuth context={authStep} />;
case "passkey_create":
return <AddPasskey />;
case "complete":
case "initial":
default:
return <MainAuthContent {...props} />;
}
};
2 changes: 2 additions & 0 deletions packages/alchemy/src/react/components/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
export type AuthStep =
| { type: "email_verify"; email: string }
| { type: "passkey_verify"; error?: Error }
| { type: "passkey_create" }
| { type: "email_completing"; createPasskeyAfter?: boolean }
| { type: "initial" }
| { type: "complete" };

Expand Down
16 changes: 14 additions & 2 deletions packages/alchemy/src/react/components/auth/sections/EmailAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useForm } from "@tanstack/react-form";
import { zodValidator } from "@tanstack/zod-form-adapter";
import { z } from "zod";
import { useAuthenticate } from "../../../hooks/useAuthenticate.js";
import { useSigner } from "../../../hooks/useSigner.js";
import { ChevronRight } from "../../../icons/chevron.js";
import { MailIcon } from "../../../icons/mail.js";
import { Button } from "../../button.js";
import { IS_SIGNUP_QP } from "../../constants.js";
import { Input } from "../../input.js";
import { useAuthContext } from "../context.js";
import type { AuthType } from "../types.js";
Expand All @@ -19,12 +21,16 @@ export const EmailAuth = ({
placeholder = "Email",
}: EmailAuthProps) => {
const { setAuthStep } = useAuthContext();
const signer = useSigner();
const { authenticateAsync, error, isPending } = useAuthenticate({
onMutate: (params) => {
onMutate: async (params) => {
if ("email" in params) {
setAuthStep({ type: "email_verify", email: params.email });
}
},
onSuccess: () => {
setAuthStep({ type: "complete" });
},
onError: (error) => {
// TODO: need to handle this and show it to the user
console.error(error);
Expand All @@ -38,7 +44,13 @@ export const EmailAuth = ({
email: "",
},
onSubmit: async ({ value: { email } }) => {
await authenticateAsync({ type: "email", email });
const existingUser = await signer?.getUser(email);
const redirectParams = new URLSearchParams();
if (existingUser == null) {
redirectParams.set(IS_SIGNUP_QP, "true");
}

await authenticateAsync({ type: "email", email, redirectParams });
},
validatorAdapter: zodValidator,
});
Expand Down
6 changes: 1 addition & 5 deletions packages/alchemy/src/react/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import {
import { GoogleIcon } from "../icons/google.js";

type ButtonProps = (
| {
type?: "primary";
icon?: never;
}
| { type: "secondary" | "link"; icon?: never }
| { type?: "primary" | "secondary" | "link"; icon?: never }
| { type: "social"; icon?: string | ReactNode }
) &
Omit<
Expand Down
1 change: 1 addition & 0 deletions packages/alchemy/src/react/components/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const IS_SIGNUP_QP = "aa-is-signup";
6 changes: 3 additions & 3 deletions packages/alchemy/src/react/components/poweredby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { AlchemyLogo } from "../icons/alchemy.js";

// eslint-disable-next-line jsdoc/require-jsdoc
export const PoweredBy = () => (
<div className="flex flex-row gap-1 items-center">
<span className="text-fg-disabled text-xs">powered by</span>
<AlchemyLogo className="fill-fg-disabled" />
<div className="flex flex-row gap-1 items-center text-fg-disabled">
<span className="text-xs">powered by</span>
<AlchemyLogo />
</div>
);
62 changes: 46 additions & 16 deletions packages/alchemy/src/react/context.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"use client";

import type { NoUndefined } from "@alchemy/aa-core";
import type { QueryClient } from "@tanstack/react-query";
import { createContext, useContext, useRef } from "react";
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
import type { AlchemyAccountsConfig, AlchemyClientState } from "../config";
import { AuthCard, type AuthCardProps } from "./components/auth/card/index.js";
import { IS_SIGNUP_QP } from "./components/constants.js";
import { NoAlchemyAccountContextError } from "./errors.js";
import { useSignerStatus } from "./hooks/useSignerStatus.js";
import { Hydrate } from "./hydrate.js";

export type AlchemyAccountContextProps =
Expand All @@ -31,14 +34,25 @@ export type AlchemyAccountsProviderProps = {
* If auth config is provided, then the auth modal will be added
* to the DOM and can be controlled via the `useAuthModal` hook
*/
auth?: AuthCardProps;
auth?: AuthCardProps & { addPasskeyOnSignup?: boolean };
};
};

export const useAlchemyAccountContext = () => {
/**
* Internal Only hook used to access the alchemy account context.
* This hook is meant to be consumed by other hooks exported by this package.
*
* @param override optional context override that can be used to return a custom context
* @returns The alchemy account context if one exists
* @throws if used outside of the AlchemyAccountProvider
*/
export const useAlchemyAccountContext = (
override?: AlchemyAccountContextProps
): NoUndefined<AlchemyAccountContextProps> => {
const context = useContext(AlchemyAccountContext);
if (override != null) return override;

if (context === undefined) {
if (context == null) {
throw new NoAlchemyAccountContextError("useAlchemyAccountContext");
}

Expand All @@ -63,20 +77,36 @@ export const AlchemyAccountProvider = (
const openAuthModal = () => ref.current?.showModal();
const closeAuthModal = () => ref.current?.close();

const initialContext = useMemo(
() => ({
config,
queryClient,
ui: uiConfig
? {
openAuthModal,
closeAuthModal,
}
: undefined,
}),
[config, queryClient, uiConfig]
);
const { status } = useSignerStatus(initialContext);

useEffect(() => {
if (
status === "AWAITING_EMAIL_AUTH" &&
uiConfig?.auth?.addPasskeyOnSignup
) {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get(IS_SIGNUP_QP) !== "true") return;

openAuthModal();
}
}, [status, uiConfig?.auth]);

return (
<Hydrate {...props}>
<AlchemyAccountContext.Provider
value={{
config,
queryClient,
ui: uiConfig
? {
openAuthModal,
closeAuthModal,
}
: undefined,
}}
>
<AlchemyAccountContext.Provider value={initialContext}>
{children}
{uiConfig?.auth && (
<dialog
Expand Down
Loading

0 comments on commit ab6bad3

Please sign in to comment.