Skip to content

Commit

Permalink
feat: oauth add passkey after signup (redirect flow) (#1140)
Browse files Browse the repository at this point in the history
* 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 <michael.moldoveanu@alchemy.com>

* chore: remove logging

Co-authored-by: Michael Moldoveanu <michael.moldoveanu@alchemy.com>

* chore: reset oauth mode to popup

* fix: handle undefined isNewUser within emitNewUserEvent wrapper

* chore: remove unnecessary bang

---------

Co-authored-by: Michael Moldoveanu <michael.moldoveanu@alchemy.com>
  • Loading branch information
linnall and moldy530 authored Nov 13, 2024
1 parent aa3ebf9 commit 6d5e229
Show file tree
Hide file tree
Showing 11 changed files with 123 additions and 59 deletions.
3 changes: 3 additions & 0 deletions account-kit/core/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
21 changes: 9 additions & 12 deletions account-kit/react/src/components/auth/card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { disconnect } from "@account-kit/core";
import {
useCallback,
useEffect,
useMemo,
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";
Expand All @@ -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;
};
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -106,16 +105,18 @@ 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" });
} else {
setAuthStep({ type: "initial" });
}
closeAuthModal();
}, [authStep.type, closeAuthModal, setAuthStep, config]);
}, [isConnected, authStep.type, closeAuthModal, config, setAuthStep]);

useEffect(() => {
if (authStep.type === "complete") {
Expand All @@ -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,
Expand All @@ -139,6 +135,7 @@ export const AuthCardContent = ({
openAuthModal,
closeAuthModal,
addPasskeyOnSignup,
isConnected,
]);

return (
Expand Down
8 changes: 6 additions & 2 deletions account-kit/react/src/components/auth/card/loading/oauth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions account-kit/react/src/components/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type AuthStep =
| {
type: "oauth_completing";
config: Extract<AuthType, { type: "social" }>;
createPasskeyAfter?: boolean;
error?: Error;
}
| { type: "initial"; error?: Error }
Expand Down
29 changes: 25 additions & 4 deletions account-kit/react/src/components/auth/modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog isOpen={isOpen} onClose={closeAuthModal}>
Expand Down
22 changes: 22 additions & 0 deletions account-kit/react/src/hooks/internal/useNewUserSignup.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
23 changes: 0 additions & 23 deletions account-kit/react/src/hooks/useUiConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlchemyAccountsUIConfig>,
Expand Down Expand Up @@ -96,31 +93,11 @@ export function UiConfigProvider({
children,
initialConfig,
}: PropsWithChildren<{ initialConfig?: AlchemyAccountsUIConfig }>) {
const { isConnected } = useSignerStatus();
const storeRef = useRef<StoreApi<UiConfigStore>>();
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 (
<UiConfigContext.Provider value={storeRef.current}>
{children}
Expand Down
27 changes: 25 additions & 2 deletions account-kit/signer/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type AlchemySignerStore = {
user: User | null;
status: AlchemySignerStatus;
error: ErrorInfo | null;
isNewUser?: boolean;
};

type InternalStore = Mutate<
Expand Down Expand Up @@ -152,6 +153,14 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
),
{ 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}`);
}
Expand Down Expand Up @@ -715,6 +724,9 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
authenticatingType: "email",
});

// fire new user event
this.emitNewUserEvent(params.isNewUser);

return user;
}
};
Expand Down Expand Up @@ -777,15 +789,21 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
bundle,
orgId,
idToken,
}: Extract<AuthParams, { type: "oauthReturn" }>): Promise<User> =>
this.inner.completeAuthWithBundle({
isNewUser,
}: Extract<AuthParams, { type: "oauthReturn" }>): Promise<User> => {
const user = this.inner.completeAuthWithBundle({
bundle,
orgId,
connectedEventName: "connectedOauth",
authenticatingType: "oauth",
idToken,
});

this.emitNewUserEvent(isNewUser);

return user;
};

private registerListeners = () => {
this.sessionManager.on("connected", (session) => {
this.store.setState({
Expand Down Expand Up @@ -831,6 +849,11 @@ export abstract class BaseAlchemySigner<TClient extends BaseSignerClient>
});
});
};

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 {
Expand Down
39 changes: 27 additions & 12 deletions account-kit/signer/src/signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +36,7 @@ export type AuthParams =
bundle: string;
orgId: string;
idToken: string;
isNewUser?: boolean;
};

export type OauthProviderConfig =
Expand Down Expand Up @@ -110,16 +111,23 @@ export class AlchemyWebSigner extends BaseAlchemySigner<AlchemySignerWebClient>
} 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
Expand All @@ -128,14 +136,21 @@ export class AlchemyWebSigner extends BaseAlchemySigner<AlchemySignerWebClient>

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,
});
}
}
Expand Down
1 change: 1 addition & 0 deletions account-kit/signer/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions examples/ui-demo/src/hooks/useBreakpoint.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -16,10 +16,10 @@ type Breakpoint = keyof typeof breakpoints;
*
* @returns {Breakpoint} The current active breakpoint
*/
export const useBreakpoint = (): Breakpoint => {
const [currentBreakpoint, setCurrentBreakpoint] = useState<Breakpoint>("sm");
export const useBreakpoint = (): Breakpoint | undefined => {
const [currentBreakpoint, setCurrentBreakpoint] = useState<Breakpoint>();

useEffect(() => {
useLayoutEffect(() => {
const getBreakpoint = (width: number): Breakpoint => {
if (width >= breakpoints["2xl"]) return "2xl";
if (width >= breakpoints.xl) return "xl";
Expand Down

0 comments on commit 6d5e229

Please sign in to comment.