From 6d5e229e24402344b214df9689ca18d8f5954922 Mon Sep 17 00:00:00 2001 From: Linna <38363056+linnall@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:19:25 -0500 Subject: [PATCH] feat: oauth add passkey after signup (redirect flow) (#1140) * chore: local dev changes updated * chore: wip oauth redirect with add passkey after signup * chore: wip * refactor: remove a few things so we can test redirect * refactor: just render the pop-up on completed login for oauth * refactor: add new user signup event in signer * refactor: add hook to listen to signup events * chore: wip * refactor: use signup event to popup passkey create * fix: the modal was staying open * fix: move setAuthStep in the correct card * fix: infinite subscribe loop * fix: don't open passkey create until logged in * chore: remove local dev callback url * chore: removes unecessary bangs and refactors for readability * chore: removes unused usePrevious hook * fix: update useNewUserSignup to remove listener on dismount and when dependencies change * docs: link related discussion to TODO * refactor: adds emitNewUserEvent wrapper * chore: restore oauth mode to popup * chore: remove logging Co-authored-by: Michael Moldoveanu * chore: remove logging Co-authored-by: Michael Moldoveanu * chore: reset oauth mode to popup * fix: handle undefined isNewUser within emitNewUserEvent wrapper * chore: remove unnecessary bang --------- Co-authored-by: Michael Moldoveanu --- account-kit/core/src/store/store.ts | 3 ++ .../react/src/components/auth/card/index.tsx | 21 +++++----- .../components/auth/card/loading/oauth.tsx | 8 +++- .../react/src/components/auth/context.ts | 1 + .../react/src/components/auth/modal.tsx | 29 ++++++++++++-- .../src/hooks/internal/useNewUserSignup.ts | 22 +++++++++++ account-kit/react/src/hooks/useUiConfig.tsx | 23 ----------- account-kit/signer/src/base.ts | 27 ++++++++++++- account-kit/signer/src/signer.ts | 39 +++++++++++++------ account-kit/signer/src/types.ts | 1 + examples/ui-demo/src/hooks/useBreakpoint.tsx | 8 ++-- 11 files changed, 123 insertions(+), 59 deletions(-) create mode 100644 account-kit/react/src/hooks/internal/useNewUserSignup.ts diff --git a/account-kit/core/src/store/store.ts b/account-kit/core/src/store/store.ts index 3fb082e481..ae03d51433 100644 --- a/account-kit/core/src/store/store.ts +++ b/account-kit/core/src/store/store.ts @@ -273,6 +273,9 @@ const addClientSideStoreListeners = (store: Store) => { })); }); + // TODO: handle this appropriately, see https://github.com/alchemyplatform/aa-sdk/pull/1140#discussion_r1837265706 + // signer.on("newUserSignup", () => console.log("got new user signup")); + signer.on("connected", (user) => store.setState({ user })); signer.on("disconnected", () => { diff --git a/account-kit/react/src/components/auth/card/index.tsx b/account-kit/react/src/components/auth/card/index.tsx index f991672916..5711e8ac08 100644 --- a/account-kit/react/src/components/auth/card/index.tsx +++ b/account-kit/react/src/components/auth/card/index.tsx @@ -1,5 +1,6 @@ "use client"; +import { disconnect } from "@account-kit/core"; import { useCallback, useEffect, @@ -7,6 +8,7 @@ import { useRef, type PropsWithChildren, } from "react"; +import { useAlchemyAccountContext } from "../../../context.js"; import { useAuthConfig } from "../../../hooks/internal/useAuthConfig.js"; import { useAuthModal } from "../../../hooks/useAuthModal.js"; import { useElementHeight } from "../../../hooks/useElementHeight.js"; @@ -15,9 +17,6 @@ import { Navigation } from "../../navigation.js"; import { useAuthContext } from "../context.js"; import { Footer } from "../sections/Footer.js"; import { Step } from "./steps.js"; -import { disconnect } from "@account-kit/core"; -import { useAlchemyAccountContext } from "../../../context.js"; - export type AuthCardProps = { className?: string; }; @@ -58,7 +57,7 @@ export const AuthCardContent = ({ showClose?: boolean; }) => { const { openAuthModal, closeAuthModal } = useAuthModal(); - const { status, isAuthenticating } = useSignerStatus(); + const { status, isAuthenticating, isConnected } = useSignerStatus(); const { authStep, setAuthStep } = useAuthContext(); const { config } = useAlchemyAccountContext(); @@ -106,8 +105,10 @@ export const AuthCardContent = ({ }, [authStep, setAuthStep, config]); const onClose = useCallback(() => { - // Terminate any inflight authentication - disconnect(config); + if (!isConnected) { + // Terminate any inflight authentication + disconnect(config); + } if (authStep.type === "passkey_create") { setAuthStep({ type: "complete" }); @@ -115,7 +116,7 @@ export const AuthCardContent = ({ setAuthStep({ type: "initial" }); } closeAuthModal(); - }, [authStep.type, closeAuthModal, setAuthStep, config]); + }, [isConnected, authStep.type, closeAuthModal, config, setAuthStep]); useEffect(() => { if (authStep.type === "complete") { @@ -124,11 +125,6 @@ export const AuthCardContent = ({ onAuthSuccess?.(); } else if (authStep.type !== "initial") { didGoBack.current = false; - } else if (!didGoBack.current && isAuthenticating) { - setAuthStep({ - type: "email_completing", - createPasskeyAfter: addPasskeyOnSignup, - }); } }, [ authStep, @@ -139,6 +135,7 @@ export const AuthCardContent = ({ openAuthModal, closeAuthModal, addPasskeyOnSignup, + isConnected, ]); return ( diff --git a/account-kit/react/src/components/auth/card/loading/oauth.tsx b/account-kit/react/src/components/auth/card/loading/oauth.tsx index 9dbf963952..c592832c58 100644 --- a/account-kit/react/src/components/auth/card/loading/oauth.tsx +++ b/account-kit/react/src/components/auth/card/loading/oauth.tsx @@ -13,9 +13,13 @@ export const CompletingOAuth = () => { useEffect(() => { if (isConnected) { - setAuthStep({ type: "complete" }); + if (authStep.createPasskeyAfter) { + setAuthStep({ type: "passkey_create" }); + } else { + setAuthStep({ type: "complete" }); + } } - }, [isConnected, setAuthStep]); + }, [authStep.createPasskeyAfter, isConnected, setAuthStep]); if (authStep.error) { return ( diff --git a/account-kit/react/src/components/auth/context.ts b/account-kit/react/src/components/auth/context.ts index 4636bd0c7f..d889eb4ad1 100644 --- a/account-kit/react/src/components/auth/context.ts +++ b/account-kit/react/src/components/auth/context.ts @@ -13,6 +13,7 @@ export type AuthStep = | { type: "oauth_completing"; config: Extract; + createPasskeyAfter?: boolean; error?: Error; } | { type: "initial"; error?: Error } diff --git a/account-kit/react/src/components/auth/modal.tsx b/account-kit/react/src/components/auth/modal.tsx index bea70c0765..6da25f4acb 100644 --- a/account-kit/react/src/components/auth/modal.tsx +++ b/account-kit/react/src/components/auth/modal.tsx @@ -1,13 +1,34 @@ +import { useCallback } from "react"; +import { useNewUserSignup } from "../../hooks/internal/useNewUserSignup.js"; import { useAuthModal } from "../../hooks/useAuthModal.js"; +import { useSignerStatus } from "../../hooks/useSignerStatus.js"; import { useUiConfig } from "../../hooks/useUiConfig.js"; import { Dialog } from "../dialog/dialog.js"; import { AuthCardContent } from "./card/index.js"; +import { useAuthContext } from "./context.js"; export const AuthModal = () => { - const { modalBaseClassName } = useUiConfig(({ modalBaseClassName }) => ({ - modalBaseClassName, - })); - const { isOpen, closeAuthModal } = useAuthModal(); + const { isConnected } = useSignerStatus(); + const { modalBaseClassName, addPasskeyOnSignup } = useUiConfig( + ({ modalBaseClassName, auth }) => ({ + modalBaseClassName, + addPasskeyOnSignup: auth?.addPasskeyOnSignup, + }) + ); + + const { setAuthStep } = useAuthContext(); + const { isOpen, closeAuthModal, openAuthModal } = useAuthModal(); + + const handleSignup = useCallback(() => { + if (addPasskeyOnSignup && !isOpen) { + openAuthModal(); + setAuthStep({ + type: "passkey_create", + }); + } + }, [addPasskeyOnSignup, isOpen, openAuthModal, setAuthStep]); + + useNewUserSignup(handleSignup, isConnected); return ( diff --git a/account-kit/react/src/hooks/internal/useNewUserSignup.ts b/account-kit/react/src/hooks/internal/useNewUserSignup.ts new file mode 100644 index 0000000000..b4e422dd1f --- /dev/null +++ b/account-kit/react/src/hooks/internal/useNewUserSignup.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; +import { useSigner } from "../useSigner.js"; + +export function useNewUserSignup(onSignup: () => void, enabled?: boolean) { + const hasHandled = useRef(false); + const signer = useSigner(); + + useEffect(() => { + if (!enabled) return; + if (!signer) return; + + const handleSignup = () => { + if (!hasHandled.current) { + hasHandled.current = true; + onSignup(); + } + }; + + const stopListening = signer.on("newUserSignup", handleSignup); + return stopListening; + }, [enabled, onSignup, signer]); +} diff --git a/account-kit/react/src/hooks/useUiConfig.tsx b/account-kit/react/src/hooks/useUiConfig.tsx index 38535a08e4..cdbac4db8a 100644 --- a/account-kit/react/src/hooks/useUiConfig.tsx +++ b/account-kit/react/src/hooks/useUiConfig.tsx @@ -3,18 +3,15 @@ import { createContext, useContext, - useEffect, useRef, type PropsWithChildren, } from "react"; import { create, useStore, type StoreApi } from "zustand"; import { useShallow } from "zustand/react/shallow"; -import { IS_SIGNUP_QP } from "../components/constants.js"; import type { AlchemyAccountsUIConfig, AuthIllustrationStyle, } from "../types.js"; -import { useSignerStatus } from "./useSignerStatus.js"; type AlchemyAccountsUIConfigWithDefaults = Omit< Required, @@ -96,31 +93,11 @@ export function UiConfigProvider({ children, initialConfig, }: PropsWithChildren<{ initialConfig?: AlchemyAccountsUIConfig }>) { - const { isConnected } = useSignerStatus(); const storeRef = useRef>(); if (!storeRef.current) { storeRef.current = createUiConfigStore(initialConfig); } - const { setModalOpen, addPasskeyOnSignup } = useStore( - storeRef.current, - useShallow(({ setModalOpen, auth }) => ({ - setModalOpen, - addPasskeyOnSignup: auth?.addPasskeyOnSignup, - })) - ); - - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - if ( - isConnected && - addPasskeyOnSignup && - urlParams.get(IS_SIGNUP_QP) === "true" - ) { - setModalOpen(true); - } - }, [addPasskeyOnSignup, isConnected, setModalOpen]); - return ( {children} diff --git a/account-kit/signer/src/base.ts b/account-kit/signer/src/base.ts index 6ed409d9a7..ecd2a973ae 100644 --- a/account-kit/signer/src/base.ts +++ b/account-kit/signer/src/base.ts @@ -46,6 +46,7 @@ type AlchemySignerStore = { user: User | null; status: AlchemySignerStatus; error: ErrorInfo | null; + isNewUser?: boolean; }; type InternalStore = Mutate< @@ -152,6 +153,14 @@ export abstract class BaseAlchemySigner ), { fireImmediately: true } ); + case "newUserSignup": + return this.store.subscribe( + ({ isNewUser }) => isNewUser, + (isNewUser) => { + if (isNewUser) (listener as AlchemySignerEvents["newUserSignup"])(); + }, + { fireImmediately: true } + ); default: assertNever(event, `Unknown event type ${event}`); } @@ -715,6 +724,9 @@ export abstract class BaseAlchemySigner authenticatingType: "email", }); + // fire new user event + this.emitNewUserEvent(params.isNewUser); + return user; } }; @@ -777,8 +789,9 @@ export abstract class BaseAlchemySigner bundle, orgId, idToken, - }: Extract): Promise => - this.inner.completeAuthWithBundle({ + isNewUser, + }: Extract): Promise => { + const user = this.inner.completeAuthWithBundle({ bundle, orgId, connectedEventName: "connectedOauth", @@ -786,6 +799,11 @@ export abstract class BaseAlchemySigner idToken, }); + this.emitNewUserEvent(isNewUser); + + return user; + }; + private registerListeners = () => { this.sessionManager.on("connected", (session) => { this.store.setState({ @@ -831,6 +849,11 @@ export abstract class BaseAlchemySigner }); }); }; + + private emitNewUserEvent = (isNewUser?: boolean) => { + // assumes that if isNewUser is undefined it is a returning user + if (isNewUser) this.store.setState({ isNewUser }); + }; } function toErrorInfo(error: unknown): ErrorInfo { diff --git a/account-kit/signer/src/signer.ts b/account-kit/signer/src/signer.ts index 207a2fb5df..64585f152b 100644 --- a/account-kit/signer/src/signer.ts +++ b/account-kit/signer/src/signer.ts @@ -9,7 +9,7 @@ import { SessionManagerParamsSchema } from "./session/manager.js"; export type AuthParams = | { type: "email"; email: string; redirectParams?: URLSearchParams } - | { type: "email"; bundle: string; orgId?: string } + | { type: "email"; bundle: string; orgId?: string; isNewUser?: boolean } | { type: "passkey"; email: string; @@ -36,6 +36,7 @@ export type AuthParams = bundle: string; orgId: string; idToken: string; + isNewUser?: boolean; }; export type OauthProviderConfig = @@ -110,16 +111,23 @@ export class AlchemyWebSigner extends BaseAlchemySigner } else { client = params_.client; } - const { emailBundle, oauthBundle, oauthOrgId, oauthError, idToken } = - getAndRemoveQueryParams({ - emailBundle: "bundle", - // We don't need this, but we still want to remove it from the URL. - emailOrgId: "orgId", - oauthBundle: "alchemy-bundle", - oauthOrgId: "alchemy-org-id", - oauthError: "alchemy-error", - idToken: "alchemy-id-token", - }); + const { + emailBundle, + oauthBundle, + oauthOrgId, + oauthError, + idToken, + isSignup, + } = getAndRemoveQueryParams({ + emailBundle: "bundle", + // We don't need this, but we still want to remove it from the URL. + emailOrgId: "orgId", + oauthBundle: "alchemy-bundle", + oauthOrgId: "alchemy-org-id", + oauthError: "alchemy-error", + idToken: "alchemy-id-token", + isSignup: "aa-is-signup", + }); const initialError = oauthError != null @@ -128,14 +136,21 @@ export class AlchemyWebSigner extends BaseAlchemySigner super({ client, sessionConfig, initialError }); + const isNewUser = isSignup === "true"; + if (emailBundle) { - this.authenticate({ type: "email", bundle: emailBundle }); + this.authenticate({ + type: "email", + bundle: emailBundle, + isNewUser, + }); } else if (oauthBundle && oauthOrgId && idToken) { this.authenticate({ type: "oauthReturn", bundle: oauthBundle, orgId: oauthOrgId, idToken, + isNewUser, }); } } diff --git a/account-kit/signer/src/types.ts b/account-kit/signer/src/types.ts index 8cf614af33..9e17a05329 100644 --- a/account-kit/signer/src/types.ts +++ b/account-kit/signer/src/types.ts @@ -2,6 +2,7 @@ import type { User } from "./client/types"; export type AlchemySignerEvents = { connected(user: User): void; + newUserSignup(): void; disconnected(): void; statusChanged(status: AlchemySignerStatus): void; errorChanged(error: ErrorInfo | undefined): void; diff --git a/examples/ui-demo/src/hooks/useBreakpoint.tsx b/examples/ui-demo/src/hooks/useBreakpoint.tsx index 51d72c1d9e..742ca6b03c 100644 --- a/examples/ui-demo/src/hooks/useBreakpoint.tsx +++ b/examples/ui-demo/src/hooks/useBreakpoint.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useLayoutEffect, useState } from "react"; // Define the breakpoints based on Tailwind CSS defaults (or your custom Tailwind configuration) const breakpoints = { @@ -16,10 +16,10 @@ type Breakpoint = keyof typeof breakpoints; * * @returns {Breakpoint} The current active breakpoint */ -export const useBreakpoint = (): Breakpoint => { - const [currentBreakpoint, setCurrentBreakpoint] = useState("sm"); +export const useBreakpoint = (): Breakpoint | undefined => { + const [currentBreakpoint, setCurrentBreakpoint] = useState(); - useEffect(() => { + useLayoutEffect(() => { const getBreakpoint = (width: number): Breakpoint => { if (width >= breakpoints["2xl"]) return "2xl"; if (width >= breakpoints.xl) return "xl";