diff --git a/docs-site b/docs-site index 5499986934..25153f44c8 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit 54999869347cd3de075ff6f9a350c1c5b3bdc9ef +Subproject commit 25153f44c8771b3ed33cbcff5bd240a4c58692ef diff --git a/docs/docs.yml b/docs/docs.yml index af5d552e2c..d13cd96478 100644 --- a/docs/docs.yml +++ b/docs/docs.yml @@ -1083,6 +1083,8 @@ navigation: contents: - section: Functions contents: + - page: addPasskey + path: wallets/pages/reference/alchemy/wagmi-core/functions/addPasskey.mdx - page: createConfig path: wallets/pages/reference/alchemy/wagmi-core/functions/createConfig.mdx - page: getAuthClient @@ -1097,6 +1099,8 @@ navigation: path: wallets/pages/reference/alchemy/wagmi-core/functions/listAuthMethods.mdx - page: loginWithOauth path: wallets/pages/reference/alchemy/wagmi-core/functions/loginWithOauth.mdx + - page: loginWithPasskey + path: wallets/pages/reference/alchemy/wagmi-core/functions/loginWithPasskey.mdx - page: prepareCalls path: wallets/pages/reference/alchemy/wagmi-core/functions/prepareCalls.mdx - page: prepareSwap diff --git a/docs/pages/reference/alchemy/react/components/AlchemyUiProvider.mdx b/docs/pages/reference/alchemy/react/components/AlchemyUiProvider.mdx new file mode 100644 index 0000000000..b3e00c286d --- /dev/null +++ b/docs/pages/reference/alchemy/react/components/AlchemyUiProvider.mdx @@ -0,0 +1,72 @@ +--- +# This file is autogenerated +title: AlchemyUiProvider +description: Overview of the AlchemyUiProvider method +slug: wallets/reference/alchemy/react/components/AlchemyUiProvider +--- + +Provider for for Alchemy UI components. `AuthCard` and `AuthModal` can only be rendered within this provider. + +## Import + +```ts +import { AlchemyUiProvider } from "@alchemy/react"; +``` + +## Usage + +```tsx +import { AlchemyUiProvider, createConfig } from "@account-kit/react"; +import { sepolia } from "@account-kit/infra"; +import { QueryClient } from "@tanstack/react-query"; +import { wagmiConfig } from "./wagmiConfig"; + +const ui: AlchemyAccountsUIConfig = { + illustrationStyle: "linear", + auth: { + sections: [ + [{ type: "email" }], + [{ type: "social", authProviderId: "google", mode: "popup" }], + ], + }, +}; + +const queryClient = new QueryClient(); + +function App({ children }: React.PropsWithChildren) { + return ( + + + {children} + + + ); +} +``` + +## Parameters + +### props + +`React.PropsWithChildren` +alchemy accounts provider props + +### props.queryClient + +`QueryClient` +the react-query query client to use + +### props.ui + +`AlchemyAccountsUIConfig` +ui configuration to use + +### props.children + +`React.ReactNode | undefined` +react components that should have this accounts context + +## Returns + +`React.JSX.Element` +The element to wrap your application in for Alchemy UI context. diff --git a/docs/pages/reference/alchemy/react/components/AuthCard.mdx b/docs/pages/reference/alchemy/react/components/AuthCard.mdx new file mode 100644 index 0000000000..bc81259959 --- /dev/null +++ b/docs/pages/reference/alchemy/react/components/AuthCard.mdx @@ -0,0 +1,41 @@ +--- +# This file is autogenerated +title: AuthCard +description: Overview of the AuthCard method +slug: wallets/reference/alchemy/react/components/AuthCard +--- + +React component containing an Auth view with configured auth methods +and options based on the config passed to the AlchemyAccountProvider + +## Import + +```ts +import { AuthCard } from "@alchemy/react"; +``` + +## Usage + +```tsx +import { AuthCard, useAlchemyAccountContext } from "@account-kit/react"; + +function ComponentWithAuthCard() { + // assumes you've passed in a UI config to the Account Provider + // you can also directly set the properties on the AuthCard component + const { uiConfig } = useAlchemyAccountContext(); + + return ; +} +``` + +## Parameters + +### props + +`AuthCardProps` +Card Props + +## Returns + +`JSX.Element` +a react component containing the AuthCard diff --git a/docs/pages/reference/alchemy/react/components/AuthModal.mdx b/docs/pages/reference/alchemy/react/components/AuthModal.mdx new file mode 100644 index 0000000000..af12f50ddc --- /dev/null +++ b/docs/pages/reference/alchemy/react/components/AuthModal.mdx @@ -0,0 +1,19 @@ +--- +# This file is autogenerated +title: AuthModal +description: Overview of the AuthModal method +slug: wallets/reference/alchemy/react/components/AuthModal +--- + +Renders the Auth Modal component. Must be rendered within an `AlchemyUiProvider`. To customize this modal, use the `ui` prop of the `AlchemyUiProvider`. + +## Import + +```ts +import { AuthModal } from "@alchemy/react"; +``` + +## Returns + +`React.JSX.Element` +The rendered Auth Modal component. diff --git a/docs/pages/reference/alchemy/react/hooks/useAddPasskey.mdx b/docs/pages/reference/alchemy/react/hooks/useAddPasskey.mdx new file mode 100644 index 0000000000..dd1b02d3f7 --- /dev/null +++ b/docs/pages/reference/alchemy/react/hooks/useAddPasskey.mdx @@ -0,0 +1,39 @@ +--- +# This file is autogenerated +title: useAddPasskey +description: Overview of the useAddPasskey method +slug: wallets/reference/alchemy/react/hooks/useAddPasskey +--- + +React hook for adding a passkey to an already authenticated account. + +This hook uses the `addPasskey` mutation to add a passkey to the authenticated account. + +## Import + +```ts +import { useAddPasskey } from "@alchemy/react"; +``` + +## Usage + +```tsx twoslash +import { useAddPasskey } from "@alchemy/react"; + +function AddPasskeyForm() { + const { addPasskey, isPending } = useAddPasskey(); +} +``` + +## Parameters + +### parameters + +`UseAddPasskeyParameters` + +- Configuration options for the hook + +## Returns + +`UseAddPasskeyReturnType` +TanStack Query mutation object diff --git a/docs/pages/reference/alchemy/react/hooks/useAuthModal.mdx b/docs/pages/reference/alchemy/react/hooks/useAuthModal.mdx new file mode 100644 index 0000000000..b9188e4f66 --- /dev/null +++ b/docs/pages/reference/alchemy/react/hooks/useAuthModal.mdx @@ -0,0 +1,37 @@ +--- +# This file is autogenerated +title: useAuthModal +description: Overview of the useAuthModal method +slug: wallets/reference/alchemy/react/hooks/useAuthModal +--- + +A [hook](https://github.com/alchemyplatform/aa-sdk/blob/main/account-kit/react/src/hooks/useAuthModal.ts) that returns the open and close functions for the Auth Modal if uiConfig +is enabled on the Account Provider + +## Import + +```ts +import { useAuthModal } from "@alchemy/react"; +``` + +## Usage + +```tsx twoslash +import React from "react"; +import { useAuthModal } from "@account-kit/react"; + +const ComponentWithAuthModal = () => { + const { openAuthModal } = useAuthModal(); + + return ( +
+ +
+ ); +}; +``` + +## Returns + +`UseAuthModalResult` +an object containing methods for opening or closing the auth modal. [ref](https://github.com/alchemyplatform/aa-sdk/blob/main/account-kit/react/src/hooks/useAuthModal.ts#L4) diff --git a/docs/pages/reference/alchemy/react/hooks/useLoginWithPasskey.mdx b/docs/pages/reference/alchemy/react/hooks/useLoginWithPasskey.mdx new file mode 100644 index 0000000000..1424922953 --- /dev/null +++ b/docs/pages/reference/alchemy/react/hooks/useLoginWithPasskey.mdx @@ -0,0 +1,81 @@ +--- +# This file is autogenerated +title: useLoginWithPasskey +description: Overview of the useLoginWithPasskey method +slug: wallets/reference/alchemy/react/hooks/useLoginWithPasskey +--- + +React hook for Passkey authentication - initiates authentication flow with the specified options. + +This hook wraps the `loginWithPasskey` action with React Query mutation functionality, +providing loading states, error handling, and mutation management for the OAuth authentication flow. + +## Import + +```ts +import { useLoginWithPasskey } from "@alchemy/react"; +``` + +## Usage + +```tsx twoslash +import { useLoginWithPasskey } from "@alchemy/react"; + +function LoginForm() { + const { loginWithPasskey, isPending, error } = useLoginWithPasskey(); + + const handleGoogleLogin = () => { + loginWithPasskey({ + provider: "google", + mode: "popup", // or 'redirect' + }); + }; + + return ( + + ); +} +``` + +## Parameters + +### parameters + +`UseLoginWithPasskeyParameters` + +- Configuration options for the hook + +### parameters.config + +`Config` + +- Optional wagmi config override + +### parameters.mutation + +`UseMutationParameters` + +- Optional React Query mutation configuration + +## Returns + +`UseLoginWithPasskeyReturnType` +TanStack Query mutation object with the following properties: + +- `loginWithPasskey`: `(variables: LoginWithPasskeyParameters, options?) => void` - Mutation function to initiate OAuth login +- `loginWithPasskeyAsync`: `(variables: LoginWithPasskeyParameters, options?) => Promise` - Async mutation function that returns a promise +- `data`: `LoginWithPasskeyReturnType | undefined` - The last successfully resolved data (void) +- `error`: `Error | null` - The error object for the mutation, if an error was encountered +- `isError`: `boolean` - True if the mutation is in an error state +- `isIdle`: `boolean` - True if the mutation is in its initial idle state +- `isPending`: `boolean` - True if the mutation is currently executing +- `isSuccess`: `boolean` - True if the last mutation attempt was successful +- `failureCount`: `number` - The failure count for the mutation +- `failureReason`: `Error | null` - The failure reason for the mutation retry +- `isPaused`: `boolean` - True if the mutation has been paused +- `reset`: `() => void` - Function to reset the mutation to its initial state +- `status`: `'idle' | 'pending' | 'error' | 'success'` - Current status of the mutation +- `submittedAt`: `number` - Timestamp for when the mutation was submitted +- `variables`: `LoginWithPasskeyParameters | undefined` - The variables object passed to the mutation diff --git a/docs/pages/reference/alchemy/wagmi-core/functions/addPasskey.mdx b/docs/pages/reference/alchemy/wagmi-core/functions/addPasskey.mdx new file mode 100644 index 0000000000..bead8b8a73 --- /dev/null +++ b/docs/pages/reference/alchemy/wagmi-core/functions/addPasskey.mdx @@ -0,0 +1,33 @@ +--- +# This file is autogenerated +title: addPasskey +description: Overview of the addPasskey method +slug: wallets/reference/alchemy/wagmi-core/functions/addPasskey +--- + +Adds a passkey to the authenticated user's account. + +## Import + +```ts +import { addPasskey } from "@alchemy/wagmi-core"; +``` + +## Parameters + +### config + +`Config` + +- The shared Wagmi/Alchemy config + +### parameters + +`AddPasskeyParameters` + +- The parameters for the passkey creation + +## Returns + +`Promise` +Promise that resolves when the passkey is added diff --git a/docs/pages/reference/alchemy/wagmi-core/functions/loginWithPasskey.mdx b/docs/pages/reference/alchemy/wagmi-core/functions/loginWithPasskey.mdx new file mode 100644 index 0000000000..17e9c5afe2 --- /dev/null +++ b/docs/pages/reference/alchemy/wagmi-core/functions/loginWithPasskey.mdx @@ -0,0 +1,33 @@ +--- +# This file is autogenerated +title: loginWithPasskey +description: Overview of the loginWithPasskey method +slug: wallets/reference/alchemy/wagmi-core/functions/loginWithPasskey +--- + +Initiates Passkey authentication flow with the specified parameters. + +## Import + +```ts +import { loginWithPasskey } from "@alchemy/wagmi-core"; +``` + +## Parameters + +### config + +`Config` + +- The shared Wagmi/Alchemy config + +### parameters + +`LoginWithPasskeyParameters` + +- Passkey authentication parameters + +## Returns + +`Promise` +Promise that resolves when authentication completes and connection is established diff --git a/examples/react-v5-example/src/app/globals.css b/examples/react-v5-example/src/app/globals.css index a2dc41ecee..5720672942 100644 --- a/examples/react-v5-example/src/app/globals.css +++ b/examples/react-v5-example/src/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@config '../../tailwind.config.ts'; :root { --background: #ffffff; diff --git a/examples/react-v5-example/src/app/page.tsx b/examples/react-v5-example/src/app/page.tsx index af38951147..861cc469f8 100644 --- a/examples/react-v5-example/src/app/page.tsx +++ b/examples/react-v5-example/src/app/page.tsx @@ -33,6 +33,8 @@ import { useUpdateEmail, useUpdatePhoneNumber, useAuthMethods, + AuthModal, + useAuthModal, } from "@alchemy/react"; import { zeroAddress, Address } from "viem"; import { useState } from "react"; @@ -91,10 +93,18 @@ export default function Home() { } const AuthenticationDemo = () => { + const { openAuthModal } = useAuthModal(); const [authMode, setAuthMode] = useState<"email" | "sms" | "oauth">("email"); return (
+ +
+ +
+
+ ); +}; diff --git a/packages/react/src/components/auth/card/auth-card.tsx b/packages/react/src/components/auth/card/auth-card.tsx new file mode 100644 index 0000000000..e17074ed70 --- /dev/null +++ b/packages/react/src/components/auth/card/auth-card.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + type PropsWithChildren, +} from "react"; +import { useAuthModal } from "../../../hooks/useAuthModal.js"; +import { useElementHeight } from "../../../hooks/useElementHeight.js"; +import { Navigation } from "../../navigation.js"; +import { useAuthContext } from "../context.js"; +import { Footer } from "../sections/Footer.js"; +import { Step } from "./steps.js"; +import { useAccount, useDisconnect } from "wagmi"; +import { useAuthConfig } from "../../../hooks/useAuthConfig.js"; +export type AuthCardProps = { + className?: string; +}; + +/** + * React component containing an Auth view with configured auth methods + * and options based on the config passed to the AlchemyAccountProvider + * + * @example + * ```tsx + * import { AuthCard, useAlchemyAccountContext } from "@account-kit/react"; + * + * function ComponentWithAuthCard() { + * // assumes you've passed in a UI config to the Account Provider + * // you can also directly set the properties on the AuthCard component + * const { uiConfig } = useAlchemyAccountContext(); + * + * return ( + * + * ); + * } + * ``` + * + * @param {AuthCardProps} props Card Props + * @returns {JSX.Element} a react component containing the AuthCard + */ +export const AuthCard = (props: AuthCardProps) => { + return ; +}; + +// this isn't used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const AuthCardContent = ({ + className, + showClose = false, +}: { + className?: string; + showClose?: boolean; +}) => { + const { closeAuthModal } = useAuthModal(); + const { isConnected } = useAccount(); + const { authStep, setAuthStep } = useAuthContext(); + const { disconnect } = useDisconnect(); + + const didGoBack = useRef(false); + + const { onAuthSuccess, addPasskeyOnSignup } = useAuthConfig(); + + const canGoBack = useMemo(() => { + return [ + "email_verify", + "otp_verify", + "passkey_verify", + "passkey_create", + "pick_eoa", + "wallet_connect", + "eoa_connect", + "oauth_completing", + ].includes(authStep.type); + }, [authStep]); + + const onBack = useCallback(() => { + switch (authStep.type) { + case "email_verify": + case "otp_verify": + case "passkey_verify": + case "passkey_create": + case "oauth_completing": + disconnect(); // Terminate any inflight authentication + didGoBack.current = true; + setAuthStep({ type: "initial" }); + break; + case "wallet_connect": + case "eoa_connect": + setAuthStep({ type: "pick_eoa" }); + break; + case "pick_eoa": + setAuthStep({ type: "initial" }); + break; + default: + console.warn("Unhandled back action for auth step", authStep); + } + }, [authStep, setAuthStep, disconnect]); + + const onClose = useCallback(() => { + if (!isConnected) { + // Terminate any inflight authentication + disconnect(); + } + + if (authStep.type === "passkey_create") { + setAuthStep({ type: "complete" }); + } else { + setAuthStep({ type: "initial" }); + } + closeAuthModal(); + }, [isConnected, authStep.type, closeAuthModal, disconnect, setAuthStep]); + + useEffect(() => { + if (authStep.type === "complete") { + didGoBack.current = false; + closeAuthModal(); + onAuthSuccess?.(); + } else if (authStep.type !== "initial") { + didGoBack.current = false; + } + }, [ + authStep, + setAuthStep, + onAuthSuccess, + closeAuthModal, + addPasskeyOnSignup, + isConnected, + ]); + + return ( +
+ {/* Wrapper container that sizes its height dynamically */} + + {(canGoBack || showClose) && ( + + )} +
+ +
+
+ +
+ ); +}; + +const DynamicHeight = ({ children }: PropsWithChildren) => { + const contentRef = useRef(null); + const { height } = useElementHeight(contentRef); + + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/packages/react/src/components/auth/card/content.tsx b/packages/react/src/components/auth/card/content.tsx new file mode 100644 index 0000000000..46c890f952 --- /dev/null +++ b/packages/react/src/components/auth/card/content.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; + +interface CardContentProps { + header: ReactNode | string; + icon?: ReactNode; + description: ReactNode | string; + error?: Error | string; + className?: string; + secondaryButton?: { + title: string; + onClick: () => void; + }; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export const CardContent = ({ + header, + icon, + description, + secondaryButton, + className, +}: CardContentProps) => { + return ( + <> +
+ {icon && ( +
+ {icon} +
+ )} + {header} + {typeof description === "string" ? ( +

+ {description} +

+ ) : ( + description + )} +
+ {secondaryButton && ( + + )} + + ); +}; diff --git a/packages/react/src/components/auth/card/error/connection-error.tsx b/packages/react/src/components/auth/card/error/connection-error.tsx new file mode 100644 index 0000000000..bbd86f7fbe --- /dev/null +++ b/packages/react/src/components/auth/card/error/connection-error.tsx @@ -0,0 +1,68 @@ +"use client"; +import { useEffect } from "react"; +import { ls } from "../../../strings.js"; +import { Button } from "../../../button.js"; +import { useDisconnect } from "wagmi"; + +type ConnectionErrorProps = { + headerText: string; + bodyText: string; + tryAgainCTA?: string; + icon: React.ReactNode; + handleTryAgain?: () => void; + handleUseAnotherMethod?: () => void; + shouldDisconnect?: boolean; + handleSkip?: () => void; +}; + +export const ConnectionError = ({ + headerText, + bodyText, + tryAgainCTA, + icon, + handleTryAgain, + handleUseAnotherMethod, + shouldDisconnect = true, + handleSkip, +}: ConnectionErrorProps) => { + const { disconnect } = useDisconnect(); + useEffect(() => { + // Terminate any inflight authentication on Error... + if (shouldDisconnect) { + disconnect(); + } + }, [disconnect, shouldDisconnect]); + + return ( +
+
+
+ {icon} +
+
+

{headerText}

+

{bodyText}

+ + {handleUseAnotherMethod && ( + + )} + {handleSkip && ( + + )} +
+ ); +}; diff --git a/packages/react/src/components/auth/card/error/general-error.tsx b/packages/react/src/components/auth/card/error/general-error.tsx new file mode 100644 index 0000000000..261b00b1d0 --- /dev/null +++ b/packages/react/src/components/auth/card/error/general-error.tsx @@ -0,0 +1,16 @@ +import { Warning } from "../../../../icons/warning.js"; +import { ls } from "../../../strings.js"; + +export const GeneralError = () => { + return ( +
+ +
+

+ {ls.error.general.title} +

+

{ls.error.general.body}

+
+
+ ); +}; diff --git a/packages/react/src/components/auth/card/footer/email-not-reveived.tsx b/packages/react/src/components/auth/card/footer/email-not-reveived.tsx new file mode 100644 index 0000000000..ce32e9963b --- /dev/null +++ b/packages/react/src/components/auth/card/footer/email-not-reveived.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from "react"; +import { ls } from "../../../strings.js"; +import { + AuthStepStatus, + useAuthContext, + type AuthStep, +} from "../../context.js"; +import { Button } from "../../../button.js"; +import { useSendEmailOtp } from "../../../../hooks/useSendEmailOtp.js"; + +type EmailNotReceivedDisclaimerProps = { + authStep: Extract; +}; +export const EmailNotReceivedDisclaimer = ({ + authStep, +}: EmailNotReceivedDisclaimerProps) => { + const { setAuthStep } = useAuthContext(); + const [emailResent, setEmailResent] = useState(false); + const { sendEmailOtp } = useSendEmailOtp({ + mutation: { + onSuccess: () => { + setAuthStep({ type: "complete" }); + }, + }, + }); + + 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 + setTimeout(() => { + setEmailResent(false); + }, 2000); + } + }, [emailResent]); + + return ( +
+ + {ls.loadingEmail.emailNotReceived} + + +
+ ); +}; diff --git a/packages/react/src/components/auth/card/footer/help-text.tsx b/packages/react/src/components/auth/card/footer/help-text.tsx new file mode 100644 index 0000000000..f008717b6f --- /dev/null +++ b/packages/react/src/components/auth/card/footer/help-text.tsx @@ -0,0 +1,18 @@ +import { useUiConfig } from "../../../../hooks/useUiConfig.js"; +import { ls } from "../../../strings.js"; + +export const HelpText = () => { + const supportUrl = useUiConfig(({ supportUrl }) => supportUrl); + + if (!supportUrl) return null; + return ( +
+ + {ls.loadingPasskey.supportText} + + + {ls.loadingPasskey.supportLink} + +
+ ); +}; diff --git a/packages/react/src/components/auth/card/footer/oauth-contact-support.tsx b/packages/react/src/components/auth/card/footer/oauth-contact-support.tsx new file mode 100644 index 0000000000..ef4e25212e --- /dev/null +++ b/packages/react/src/components/auth/card/footer/oauth-contact-support.tsx @@ -0,0 +1,23 @@ +import { useUiConfig } from "../../../../hooks/useUiConfig.js"; +import { ls } from "../../../strings.js"; + +export const OAuthContactSupport = () => { + const { supportUrl } = useUiConfig(({ supportUrl }) => ({ supportUrl })); + return ( + supportUrl && ( +
+ + {ls.oauthContactSupport.title} + + + {ls.oauthContactSupport.body} + +
+ ) + ); +}; diff --git a/packages/react/src/components/auth/card/footer/protected-by.tsx b/packages/react/src/components/auth/card/footer/protected-by.tsx new file mode 100644 index 0000000000..7ada68319c --- /dev/null +++ b/packages/react/src/components/auth/card/footer/protected-by.tsx @@ -0,0 +1,16 @@ +import { AlchemyLogo } from "../../../../icons/alchemy.js"; +import { ls } from "../../../strings.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const ProtectedBy = () => ( + // eslint-disable-next-line react/jsx-no-target-blank + + {ls.protectedBy.title} + + +); diff --git a/packages/react/src/components/auth/card/footer/registration-disclaimer.tsx b/packages/react/src/components/auth/card/footer/registration-disclaimer.tsx new file mode 100644 index 0000000000..66858c9374 --- /dev/null +++ b/packages/react/src/components/auth/card/footer/registration-disclaimer.tsx @@ -0,0 +1,15 @@ +import { ls } from "../../../strings.js"; + +export const RegistrationDisclaimer = () => ( +
+ {ls.login.tosPrefix} + + {ls.login.tosLink} + +
+); diff --git a/packages/react/src/components/auth/card/loading/email.tsx b/packages/react/src/components/auth/card/loading/email.tsx new file mode 100644 index 0000000000..2da32e7931 --- /dev/null +++ b/packages/react/src/components/auth/card/loading/email.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { EmailIllustration } from "../../../../icons/illustrations/email.js"; +import { Spinner } from "../../../../icons/spinner.js"; +import { ls } from "../../../strings.js"; +import { useAuthContext } from "../../context.js"; +import { useAccount } from "wagmi"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const LoadingEmail = () => { + const { authStep } = useAuthContext("email_verify"); + // yup, re-sent and resent. I'm not fixing it + const [emailResent, setEmailResent] = useState(false); + + useEffect(() => { + if (emailResent) { + // set the text back to "Resend" after 2 seconds + setTimeout(() => { + setEmailResent(false); + }, 2000); + } + }, [emailResent]); + + return ( +
+
+ +
+ +

{ls.loadingEmail.title}

+

+ {ls.loadingEmail.verificationSent} +
+ {authStep.email} +

+
+ ); +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const CompletingEmailAuth = () => { + const { isConnected } = useAccount(); + const { setAuthStep } = useAuthContext("email_completing"); + + useEffect(() => { + if (isConnected) { + setAuthStep({ type: "complete" }); + } + }, [isConnected, setAuthStep]); + + return ( +
+
+ +
+ +

+ {ls.completingEmail.body} +

+
+ ); +}; diff --git a/packages/react/src/components/auth/card/loading/oauth.tsx b/packages/react/src/components/auth/card/loading/oauth.tsx new file mode 100644 index 0000000000..925a1ad057 --- /dev/null +++ b/packages/react/src/components/auth/card/loading/oauth.tsx @@ -0,0 +1,62 @@ +import { OauthCancelledError } from "@account-kit/signer"; +import { useEffect } from "react"; +import { + ContinueWithOAuth, + OAuthConnectionFailed, +} from "../../../../icons/oauth.js"; +import { useAuthContext } from "../../context.js"; +import { useOAuthVerify } from "../../hooks/useOAuthVerify.js"; +import { ConnectionError } from "../error/connection-error.js"; +import { ls } from "../../../strings.js"; +import { getSocialProviderDisplayName } from "../../types.js"; +import { useAccount } from "wagmi"; + +export const CompletingOAuth = () => { + const { isConnected } = useAccount(); + const { setAuthStep, authStep } = useAuthContext("oauth_completing"); + const { authenticate } = useOAuthVerify({ config: authStep.config }); + const oauthWasCancelled = authStep.error instanceof OauthCancelledError; + + useEffect(() => { + if (isConnected) { + setAuthStep({ type: "complete" }); + } else if (oauthWasCancelled) { + setAuthStep({ type: "initial" }); + } + }, [isConnected, oauthWasCancelled, setAuthStep]); + + if (authStep.error && !oauthWasCancelled) { + return ( + setAuthStep({ type: "initial" })} + icon={ + + } + /> + ); + } + + const providerDisplayName = getSocialProviderDisplayName(authStep.config); + return ( +
+
+ +
+ +

{`Continue with ${providerDisplayName}`}

+

+ {`Follow the steps in the pop up window to sign in with ${providerDisplayName}`} +

+
+ ); +}; diff --git a/packages/react/src/components/auth/card/loading/otp.tsx b/packages/react/src/components/auth/card/loading/otp.tsx new file mode 100644 index 0000000000..86b51fdbb4 --- /dev/null +++ b/packages/react/src/components/auth/card/loading/otp.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { EmailIllustration } from "../../../../icons/illustrations/email.js"; +import { ls } from "../../../strings.js"; +import { + OTPInput, + type OTPCodeType, + initialOTPValue, + isOTPCodeType, +} from "../../../otp-input/otp-input.js"; +import { Spinner } from "../../../../icons/spinner.js"; +import { AuthStepStatus, useAuthContext } from "../../context.js"; +import { useAccount } from "wagmi"; +import { useSubmitOtpCode } from "../../../../hooks/useSubmitOtpCode.js"; + +const AUTH_DELAY = 1000; + +export const LoadingOtp = () => { + const { isConnected } = useAccount(); + const { setAuthStep, authStep } = useAuthContext("otp_verify"); + const [otpCode, setOtpCode] = useState(initialOTPValue); + const [errorText, setErrorText] = useState(authStep.error?.message || ""); + const [titleText, setTitleText] = useState(ls.loadingOtp.title); + + const resetOTP = (errorText = "") => { + setOtpCode(initialOTPValue); + setErrorText(errorText); + setTitleText(ls.loadingOtp.title); + }; + + const { submitOtpCode } = useSubmitOtpCode({ + mutation: { + onError: (error: any) => { + console.error(error); + + setAuthStep({ ...authStep, error, status: AuthStepStatus.base }); + resetOTP(getUserErrorMessage(error)); + }, + onSuccess: () => { + if (isConnected) { + setAuthStep({ ...authStep, status: AuthStepStatus.success }); + setTitleText(ls.loadingOtp.verified); + + // Wait 3 seconds before completing the auth step + setTimeout(() => { + setAuthStep({ type: "complete" }); + }, AUTH_DELAY); + } + }, + }, + }); + + const setValue = async (otpCode: OTPCodeType) => { + setOtpCode(otpCode); + if (isOTPCodeType(otpCode)) { + const otp = otpCode.join(""); + + setAuthStep({ ...authStep, status: AuthStepStatus.verifying }); + setTitleText(ls.loadingOtp.verifying); + + submitOtpCode({ otpCode: otp }); + } + }; + + return ( +
+
+ + +
+

+ {titleText} +

+

+ {ls.loadingOtp.body} +

+

+ {authStep.email} +

+ +
+ ); +}; + +function getUserErrorMessage(error: Error | undefined): string { + if (!error) { + return ""; + } + // Errors from Alchemy have a JSON message. + try { + const message = JSON.parse(error.message).error; + if (message === "invalid OTP code") { + return ls.error.otp.invalid; + } + return message; + } catch (e) { + // Ignore + } + return error.message; +} diff --git a/packages/react/src/components/auth/card/loading/passkey.tsx b/packages/react/src/components/auth/card/loading/passkey.tsx new file mode 100644 index 0000000000..2d128ef2df --- /dev/null +++ b/packages/react/src/components/auth/card/loading/passkey.tsx @@ -0,0 +1,54 @@ +import { ConnectionFailed } from "../../../../icons/connectionFailed.js"; +import { LoadingPasskey } from "../../../../icons/passkey.js"; +import { ls } from "../../../strings.js"; +import { useAuthContext } from "../../context.js"; +import { usePasskeyVerify } from "../../hooks/usePasskeyVerify.js"; +import { ConnectionError } from "../error/connection-error.js"; + +export const LoadingPasskeyAuth = () => { + const { setAuthStep, authStep } = useAuthContext("passkey_verify"); + const { loginWithPasskey } = usePasskeyVerify(); + + if (authStep.error) { + return ( + } + handleTryAgain={loginWithPasskey} + handleUseAnotherMethod={() => setAuthStep({ type: "initial" })} + /> + ); + } + + return ( +
+
+ +
+ +

{ls.loadingPasskey.title}

+

+ {ls.loadingPasskey.body} +

+ +
+ {/* Hidden until we can read in support URLs from the config */} + {/*
+

+ {ls.loadingPasskey.supportText} +

+ +
*/} +
+ +
+ ); +}; diff --git a/packages/react/src/components/auth/card/main.tsx b/packages/react/src/components/auth/card/main.tsx new file mode 100644 index 0000000000..fa19bddbaf --- /dev/null +++ b/packages/react/src/components/auth/card/main.tsx @@ -0,0 +1,30 @@ +import { Fragment } from "react"; +import { Divider } from "../../divider.js"; +import { useAuthContext } from "../context.js"; +import { AuthSection } from "../sections/AuthSection.js"; +import { GeneralError } from "./error/general-error.js"; +import { useAuthConfig } from "../../../hooks/useAuthConfig.js"; + +export const MainAuthContent = () => { + const { authStep } = useAuthContext(); + const isError = authStep.type === "initial" && authStep.error; + const { header, sections, hideSignInText } = useAuthConfig(); + + return ( + <> + {header} + {!hideSignInText &&

Sign in

} + {isError && } + {sections?.map((section, idx) => { + return ( + + + {idx !== sections.length - 1 ? ( + + ) : null} + + ); + })} + + ); +}; diff --git a/packages/react/src/components/auth/card/passkey-added.tsx b/packages/react/src/components/auth/card/passkey-added.tsx new file mode 100644 index 0000000000..58207b8523 --- /dev/null +++ b/packages/react/src/components/auth/card/passkey-added.tsx @@ -0,0 +1,23 @@ +import { AddedPasskeyIllustration } from "../../../icons/illustrations/added-passkey.js"; +import { useAuthContext } from "../context.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export function PasskeyAdded() { + const { setAuthStep } = useAuthContext(); + + setTimeout(() => { + setAuthStep({ type: "complete" }); + }, 5000); + + return ( +
+
+ +
+

Passkey added!

+

+ You can use this passkey to sign in next time. +

+
+ ); +} diff --git a/packages/react/src/components/auth/card/steps.tsx b/packages/react/src/components/auth/card/steps.tsx new file mode 100644 index 0000000000..bec526e855 --- /dev/null +++ b/packages/react/src/components/auth/card/steps.tsx @@ -0,0 +1,32 @@ +import { useAuthContext } from "../context.js"; +import { AddPasskey } from "./add-passkey.js"; +import { CompletingEmailAuth, LoadingEmail } from "./loading/email.js"; +import { CompletingOAuth } from "./loading/oauth.js"; +import { LoadingPasskeyAuth } from "./loading/passkey.js"; +import { MainAuthContent } from "./main.js"; +import { PasskeyAdded } from "./passkey-added.js"; +import { LoadingOtp } from "./loading/otp.js"; + +export const Step = () => { + const { authStep } = useAuthContext(); + switch (authStep.type) { + case "email_verify": + return ; + case "otp_verify": + return ; + case "passkey_verify": + return ; + case "email_completing": + return ; + case "oauth_completing": + return ; + case "passkey_create": + return ; + case "passkey_create_success": + return ; + case "complete": + case "initial": + default: + return ; + } +}; diff --git a/packages/react/src/components/auth/context.ts b/packages/react/src/components/auth/context.ts new file mode 100644 index 0000000000..05ee8d700b --- /dev/null +++ b/packages/react/src/components/auth/context.ts @@ -0,0 +1,103 @@ +"use client"; + +import type { Connector } from "@wagmi/core"; +import { createContext, useContext } from "react"; +import type { AuthType } from "./types"; + +export enum AuthStepStatus { + base = "base", + success = "success", + error = "error", + verifying = "verifying", +} + +export type AuthStep = + | { type: "email_verify"; email: string } + | { + type: "otp_verify"; + email: string; + error?: Error; + status?: AuthStepStatus; + } + | { + type: "totp_verify"; + previousStep: "magicLink"; + factorId: string; + email: string; + error?: Error; + } + | { + type: "totp_verify"; + previousStep: "otp"; + error?: Error; + } + | { type: "passkey_verify"; error?: Error } + | { type: "passkey_create"; error?: Error } + | { type: "passkey_create_success" } + | { type: "email_completing" } + | { + type: "oauth_completing"; + config: Extract; + error?: Error; + } + | { type: "initial"; error?: Error } + | { type: "complete" } + | { type: "eoa_connect"; connector: Connector; error?: Error } + | { type: "wallet_connect"; error?: Error } + | { type: "pick_eoa" }; + +type AuthContextType< + TType extends AuthStep["type"] | undefined = AuthStep["type"] | undefined, +> = TType extends undefined + ? { + authStep: AuthStep; + setAuthStep: (step: AuthStep) => void; + resetAuthStep: () => void; + } + : { + authStep: Extract }>; + setAuthStep: (step: AuthStep) => void; + resetAuthStep: () => void; + }; + +export const AuthModalContext = createContext( + undefined, +); + +export function useAuthContext< + TType extends AuthStep["type"] | undefined = AuthStep["type"] | undefined, +>(type?: TType): AuthContextType; + +/** + * A custom hook that provides the authentication context based on the specified authentication step type. It ensures that the hook is used within an `AuthModalProvider` and throws an error if the context is not available or if the current auth step type does not match the expected type. + * + * @example + * ```tsx twoslash + * import { useAuthContext } from "@account-kit/react"; + * + * const { authStep } = useAuthContext(); + * ``` + * + * @param {AuthStep["type"]} [type] Optional type of authentication step to validate against the current context + * @returns {AuthContextType} The authentication context for the current component + * @throws Will throw an error if the hook is not used within an `AuthModalProvider` or if the current auth step type does not match the expected type + */ export function useAuthContext( + type?: AuthStep["type"] | undefined, +): AuthContextType { + const context = useOptionalAuthContext(); + + if (!context) { + throw new Error( + "useAuthModalContext must be used within a AuthModalProvider", + ); + } + + if (type && context.authStep.type !== type) { + throw new Error(`expected authstep to be ${type}`); + } + + return context; +} + +export const useOptionalAuthContext = (): AuthContextType | undefined => + useContext(AuthModalContext); diff --git a/packages/react/src/components/auth/hooks/useOAuthVerify.ts b/packages/react/src/components/auth/hooks/useOAuthVerify.ts new file mode 100644 index 0000000000..1b40de86b3 --- /dev/null +++ b/packages/react/src/components/auth/hooks/useOAuthVerify.ts @@ -0,0 +1,51 @@ +"use client"; +import { useCallback } from "react"; +import { useAuthContext } from "../context.js"; +import type { AuthType } from "../types.js"; +import { useLoginWithOauth } from "../../../hooks/useLoginWithOauth.js"; + +export type UseOAuthVerifyReturnType = { + authenticate: () => void; + isPending: boolean; +}; + +export const useOAuthVerify = ({ + config, +}: { + config: Extract; +}): UseOAuthVerifyReturnType => { + const { setAuthStep } = useAuthContext(); + const { loginWithOauth, isPending } = useLoginWithOauth({ + mutation: { + onMutate: () => { + setAuthStep({ + config, + type: "oauth_completing", + }); + }, + onError: (err) => { + console.error(err); + setAuthStep({ + type: "oauth_completing", + config, + error: err, + }); + }, + onSuccess: () => { + setAuthStep({ type: "complete" }); + }, + }, + }); + + const authenticate = useCallback(() => { + loginWithOauth({ + ...config, + provider: config.authProviderId, + }); + }, [config, loginWithOauth]); + + return { + isPending, + authenticate, + }; +}; diff --git a/packages/react/src/components/auth/hooks/usePasskeyVerify.ts b/packages/react/src/components/auth/hooks/usePasskeyVerify.ts new file mode 100644 index 0000000000..030c9da13b --- /dev/null +++ b/packages/react/src/components/auth/hooks/usePasskeyVerify.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { useAuthContext } from "../context.js"; +import { useLoginWithPasskey } from "../../../hooks/useLoginWithPasskey.js"; + +export type UsePasskeyVerifyReturnType = { + loginWithPasskey: () => void; + isPending: boolean; +}; + +/** + * Internal UI component hook used to complete passkey verification. + * + * This is used to log in with a passkey, not create a new passkey + * + * @returns {UsePasskeyVerifyReturnType} an authenticate function to do passkey verification and a boolean indicating if the operation is pending + */ +export const usePasskeyVerify = (): UsePasskeyVerifyReturnType => { + const { setAuthStep } = useAuthContext(); + + const { loginWithPasskey: loginWithPasskey_, isPending } = + useLoginWithPasskey({ + mutation: { + onMutate: () => { + setAuthStep({ type: "passkey_verify" }); + }, + onError: (err) => { + setAuthStep({ type: "passkey_verify", error: err }); + }, + onSuccess: () => { + setAuthStep({ type: "complete" }); + }, + }, + }); + + const loginWithPasskey = useCallback( + () => loginWithPasskey_({}), + [loginWithPasskey_], + ); + + return { + isPending, + loginWithPasskey, + }; +}; diff --git a/packages/react/src/components/auth/modal.tsx b/packages/react/src/components/auth/modal.tsx new file mode 100644 index 0000000000..0a08d09665 --- /dev/null +++ b/packages/react/src/components/auth/modal.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from "react"; +import { useAuthModal } from "../../hooks/useAuthModal.js"; +import { useUiConfig } from "../../hooks/useUiConfig.js"; +import { Dialog } from "../dialog/dialog.js"; +import { AuthCardContent } from "./card/auth-card.js"; +import { useAuthContext } from "./context.js"; + +/** + * Renders the Auth Modal component. Must be rendered within an `AlchemyUiProvider`. To customize this modal, use the `ui` prop of the `AlchemyUiProvider`. + * + * @returns {React.JSX.Element} The rendered Auth Modal component. + */ +export const AuthModal = () => { + const { modalBaseClassName } = useUiConfig( + ({ modalBaseClassName, auth, uiMode = "modal" }) => ({ + modalBaseClassName, + addPasskeyOnSignup: auth?.addPasskeyOnSignup, + uiMode, + }), + ); + + const { isOpen, closeAuthModal } = useAuthModal(); + const { resetAuthStep } = useAuthContext(); + + // Reset the auth step to the initial state when the modal is closed. Aside + // from generally being better UX, this prevents the modal from getting stuck + // in the "complete" state after successfully logging in and then + // disconnecting. + const previousIsOpen = useRef(isOpen); + useEffect(() => { + if (previousIsOpen.current && !isOpen) { + resetAuthStep(); + } + previousIsOpen.current = isOpen; + }, [isOpen, resetAuthStep]); + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/react/src/components/auth/sections/AuthSection.tsx b/packages/react/src/components/auth/sections/AuthSection.tsx new file mode 100644 index 0000000000..44bcd912e3 --- /dev/null +++ b/packages/react/src/components/auth/sections/AuthSection.tsx @@ -0,0 +1,32 @@ +import type { AuthType } from "../types.js"; +import { EmailAuth } from "./EmailAuth.js"; +import { ExternalWalletsAuth } from "./InjectedProvidersAuth.js"; +import { OAuth } from "./OAuth.js"; +import { PasskeyAuth } from "./PasskeyAuth.js"; + +type AuthSectionProps = { + authTypes: AuthType[]; +}; + +// This is not used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const AuthSection = ({ authTypes, ...props }: AuthSectionProps) => { + return ( +
+ {authTypes.map((authType, index) => { + switch (authType.type) { + case "email": + return ; + case "passkey": + return ; + case "social": + return ; + case "external_wallets": + return ; + default: + throw new Error("Not implemented"); + } + })} +
+ ); +}; diff --git a/packages/react/src/components/auth/sections/EmailAuth.tsx b/packages/react/src/components/auth/sections/EmailAuth.tsx new file mode 100644 index 0000000000..f5d40ee3f8 --- /dev/null +++ b/packages/react/src/components/auth/sections/EmailAuth.tsx @@ -0,0 +1,119 @@ +import { useForm } from "@tanstack/react-form"; +import { zodValidator } from "@tanstack/zod-form-adapter"; +import { memo } from "react"; +import { z } from "zod"; +import { ChevronRight } from "../../../icons/chevron.js"; +import { MailIcon } from "../../../icons/mail.js"; +import { ls } from "../../strings.js"; +import { Button } from "../../button.js"; +import { Input } from "../../input.js"; +import { useAuthContext } from "../context.js"; +import type { AuthType } from "../types.js"; +import { useSendEmailOtp } from "../../../hooks/useSendEmailOtp.js"; + +type EmailAuthProps = Extract; + +// this is not used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const EmailAuth = memo( + ({ + hideButton = false, + buttonLabel = ls.login.email.button, + placeholder = ls.login.email.placeholder, + }: EmailAuthProps) => { + const { setAuthStep } = useAuthContext(); + const { sendEmailOtp, isPending: isSendingEmailOtpPending } = + useSendEmailOtp({ + mutation: { + onMutate: (params) => { + setAuthStep({ type: "otp_verify", email: params.email }); + }, + onError: (e) => { + console.error(e); + const error = + e instanceof Error ? e : new Error("An Unknown error"); + setAuthStep({ type: "initial", error }); + }, + }, + }); + + // TODO: support magic link email + + const form = useForm({ + defaultValues: { + email: "", + }, + onSubmit: async ({ value: { email } }) => { + sendEmailOtp({ email }); + }, + validatorAdapter: zodValidator(), + }); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > +
+ + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + placeholder={placeholder} + type="email" + iconLeft={} + iconRight={ + hideButton ? ( + + ) : undefined + } + disabled={isSendingEmailOtpPending} + /> + )} + + [ + state.canSubmit, + state.isSubmitting, + state.values.email, + ]} + > + {([canSubmit, isSubmitting, email]) => + !hideButton ? ( + + ) : null + } + +
+
+ ); + }, +); diff --git a/packages/react/src/components/auth/sections/Footer.tsx b/packages/react/src/components/auth/sections/Footer.tsx new file mode 100644 index 0000000000..82bf09be3f --- /dev/null +++ b/packages/react/src/components/auth/sections/Footer.tsx @@ -0,0 +1,43 @@ +import { EmailNotReceivedDisclaimer } from "../card/footer/email-not-reveived.js"; +import { HelpText } from "../card/footer/help-text.js"; +import { OAuthContactSupport } from "../card/footer/oauth-contact-support.js"; +import { ProtectedBy } from "../card/footer/protected-by.js"; +import { RegistrationDisclaimer } from "../card/footer/registration-disclaimer.js"; +import type { AuthStep } from "../context.js"; + +type FooterProps = { + authStep: AuthStep; +}; + +const RenderFooterText = ({ authStep }: FooterProps) => { + switch (authStep.type) { + case "initial": + return ; + case "email_verify": + case "otp_verify": + return ; + case "passkey_create": + case "wallet_connect": + case "passkey_verify": + return ; + case "oauth_completing": + return ; + case "email_completing": + case "totp_verify": + case "passkey_create_success": + case "eoa_connect": + case "pick_eoa": + case "complete": + return null; + } +}; +export const Footer = ({ authStep }: FooterProps) => { + return ( +
+ +
+ +
+
+ ); +}; diff --git a/packages/react/src/components/auth/sections/InjectedProvidersAuth.tsx b/packages/react/src/components/auth/sections/InjectedProvidersAuth.tsx new file mode 100644 index 0000000000..f5ae8870d2 --- /dev/null +++ b/packages/react/src/components/auth/sections/InjectedProvidersAuth.tsx @@ -0,0 +1,17 @@ +import { WalletIcon } from "../../../icons/wallet.js"; +import { Button } from "../../button.js"; +import { useAuthContext } from "../context.js"; + +export const ExternalWalletsAuth = () => { + const { setAuthStep } = useAuthContext(); + + return ( + + ); +}; diff --git a/packages/react/src/components/auth/sections/OAuth.tsx b/packages/react/src/components/auth/sections/OAuth.tsx new file mode 100644 index 0000000000..b754282c65 --- /dev/null +++ b/packages/react/src/components/auth/sections/OAuth.tsx @@ -0,0 +1,98 @@ +import { memo } from "react"; +import { + AppleIcon, + FacebookIcon, + GoogleIcon, + TwitchIcon, +} from "../../../icons/auth-icons/index.js"; +import { assertNever } from "../../../utils.js"; +import { Button } from "../../button.js"; +import { useOAuthVerify } from "../hooks/useOAuthVerify.js"; +import type { AuthType } from "../types.js"; + +type Props = Extract; + +// Not used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const OAuth = memo(({ ...config }: Props) => { + const { authenticate } = useOAuthVerify({ config }); + + switch (config.authProviderId) { + case "google": + return ( + + ); + case "facebook": + return ( + + ); + case "apple": + return ( + + ); + case "twitch": + return ( + + ); + case "auth0": + return ( + + ); + default: + assertNever( + config, + `unhandled authProviderId ${ + (config as any).authProviderId + } passed into auth sections`, + ); + } +}); diff --git a/packages/react/src/components/auth/sections/PasskeyAuth.tsx b/packages/react/src/components/auth/sections/PasskeyAuth.tsx new file mode 100644 index 0000000000..cf1432c436 --- /dev/null +++ b/packages/react/src/components/auth/sections/PasskeyAuth.tsx @@ -0,0 +1,27 @@ +import { memo } from "react"; +import { PasskeyIcon } from "../../../icons/passkey.js"; +import { ls } from "../../strings.js"; +import { Button } from "../../button.js"; +import { usePasskeyVerify } from "../hooks/usePasskeyVerify.js"; + +type Props = { + label?: string; +}; + +// Not used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const PasskeyAuth = memo( + ({ label = ls.login.passkey.button }: Props) => { + const { loginWithPasskey } = usePasskeyVerify(); + + return ( + + ); + }, +); diff --git a/packages/react/src/components/auth/types.ts b/packages/react/src/components/auth/types.ts new file mode 100644 index 0000000000..c670696b5d --- /dev/null +++ b/packages/react/src/components/auth/types.ts @@ -0,0 +1,44 @@ +import type { + KnownAuthProvider, + OauthRedirectConfig, +} from "@account-kit/signer"; +import type { WalletConnectParameters } from "wagmi/connectors"; +import { capitalize } from "../../utils.js"; + +export type AuthType = + | { + // TODO: this should support setting redirectParams which will be added to the email redirect + type: "email"; + /** @deprecated This option will be overriden by dashboard settings. Please use the dashboard settings instead. This option will be removed in a future release. */ + emailMode?: "magicLink" | "otp"; + hideButton?: boolean; + buttonLabel?: string; + placeholder?: string; + } + | { type: "passkey" } + | { type: "external_wallets"; walletConnect?: WalletConnectParameters } + | ({ type: "social"; scope?: string; claims?: string } & ( + | { + authProviderId: "auth0"; + isCustomProvider?: false; + auth0Connection?: string; + displayName: string; + logoUrl: string; + logoUrlDark?: string; + } + | { + authProviderId: KnownAuthProvider; + isCustomProvider?: false; + auth0Connection?: never; + displayName?: never; + logoUrl?: never; + logoUrlDark?: never; + } + ) & + OauthRedirectConfig); + +export function getSocialProviderDisplayName( + authType: Extract, +): string { + return authType.displayName ?? capitalize(authType.authProviderId); +} diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx new file mode 100644 index 0000000000..2f407995b2 --- /dev/null +++ b/packages/react/src/components/button.tsx @@ -0,0 +1,46 @@ +import { + type ButtonHTMLAttributes, + type DetailedHTMLProps, + type ReactNode, +} from "react"; + +type ButtonProps = ( + | { variant?: "primary" | "secondary" | "link"; icon?: never } + | { variant: "social"; icon?: string | ReactNode } +) & + Omit< + DetailedHTMLProps< + ButtonHTMLAttributes, + HTMLButtonElement + >, + "variant" | "ref" + >; + +export const Button = ({ + variant, + children, + icon, + className, + ...props +}: ButtonProps) => { + const btnClass = (() => { + switch (variant) { + case "secondary": + return "akui-btn-secondary"; + case "social": + return "akui-btn-auth"; + case "link": + return "akui-btn-link"; + case "primary": + default: + return "akui-btn-primary"; + } + })(); + + return ( + + ); +}; diff --git a/packages/react/src/components/constants.ts b/packages/react/src/components/constants.ts new file mode 100644 index 0000000000..e668c4eef8 --- /dev/null +++ b/packages/react/src/components/constants.ts @@ -0,0 +1 @@ +export const IS_SIGNUP_QP = "aa-is-signup"; diff --git a/packages/react/src/components/dialog/dialog.tsx b/packages/react/src/components/dialog/dialog.tsx new file mode 100644 index 0000000000..57a2c40caa --- /dev/null +++ b/packages/react/src/components/dialog/dialog.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { RemoveScroll } from "react-remove-scroll"; +import { FocusTrap } from "./focustrap.js"; + +type DialogProps = { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + fullWidth?: boolean; +}; + +/** + * Dialog component that renders a modal dialog. + * + * @param {DialogProps} props - The props for the Dialog component. + * @returns {JSX.Element | null} The rendered Dialog component. + */ +export const Dialog = ({ + isOpen, + onClose, + children, + fullWidth = false, +}: DialogProps) => { + const [isScrollLocked, setScrollLocked] = useState(false); + + const [renderPortal, setRenderPortal] = useState(false); + + const dialogCardRef = useRef(null); + + const handleBackgroundClick = useCallback(() => { + onClose(); + }, [onClose]); + + useLayoutEffect(() => { + const dialogCard = dialogCardRef.current; + + if (isOpen) { + setRenderPortal(true); + return; + } + + const renderPortalHandler = () => { + setRenderPortal(false); + }; + + dialogCard?.addEventListener("animationend", renderPortalHandler); + + return () => { + dialogCard?.removeEventListener("animationend", renderPortalHandler); + }; + }, [isOpen]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + onClose(); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [isOpen, onClose]); + + useEffect(() => { + // Has to run in the browser + setScrollLocked(getComputedStyle(document.body).overflow !== "hidden"); + }, []); + + return renderPortal + ? createPortal( + + {/* Overlay */} +
+ +
event.stopPropagation()} + > + {children} +
+
+
+
, + document.body, + ) + : null; +}; diff --git a/packages/react/src/components/dialog/focustrap.tsx b/packages/react/src/components/dialog/focustrap.tsx new file mode 100644 index 0000000000..dcb2cb2130 --- /dev/null +++ b/packages/react/src/components/dialog/focustrap.tsx @@ -0,0 +1,80 @@ +// Adapted from: https://hiddedevries.nl/en/blog/2017-01-29-using-javascript-to-trap-focus-in-an-element + +import { useRef, useEffect, type PropsWithChildren } from "react"; + +function useTrapFocus() { + const ref = useRef(null); + + const handleKeyDown = (event: KeyboardEvent) => { + if (!ref.current) { + return; + } + + // Elements that can receive focus + const focusableElements = ref.current.querySelectorAll(` + a[href]:not([disabled]), + button:not([disabled]), + textarea:not([disabled]), + input[type="text"]:not([disabled]), + input[type="radio"]:not([disabled]), + input[type="checkbox"]:not([disabled]), + select:not([disabled]) + `); + + const firstFocusableElement = focusableElements[0]; + const lastFocusableElement = + focusableElements[focusableElements.length - 1]; + + const isPressingTab = event.key === "Tab"; + + if (!isPressingTab) { + return; + } + + if (event.shiftKey) { + // Shift + tab + if (document.activeElement === firstFocusableElement) { + lastFocusableElement.focus(); + event.preventDefault(); + } + } else { + // Just tab + if (document.activeElement === lastFocusableElement) { + firstFocusableElement.focus(); + event.preventDefault(); + } + } + }; + + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + + el.addEventListener("keydown", handleKeyDown); + el.focus({ preventScroll: true }); + + return () => { + el.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + return { ref }; +} + +export const FocusTrap = ({ children }: PropsWithChildren<{}>) => { + const { ref } = useTrapFocus(); + + useEffect(() => { + if (ref.current) { + ref.current.focus({ preventScroll: true }); + } + }, [ref]); + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/react/src/components/divider.tsx b/packages/react/src/components/divider.tsx new file mode 100644 index 0000000000..12217b021d --- /dev/null +++ b/packages/react/src/components/divider.tsx @@ -0,0 +1,13 @@ +import { ls } from "./strings.js"; + +// this isn't used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const Divider = () => { + return ( +
+
+

{ls.login.or}

+
+
+ ); +}; diff --git a/packages/react/src/components/error.tsx b/packages/react/src/components/error.tsx new file mode 100644 index 0000000000..05307a63ab --- /dev/null +++ b/packages/react/src/components/error.tsx @@ -0,0 +1,17 @@ +interface ErrorContainerProps { + error?: Error | string; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export const ErrorContainer = ({ error }: ErrorContainerProps) => { + return ( +
+
+ Error +
+
+ {error != null ? error.toString() : "Oops! Something went wrong"} +
+
+ ); +}; diff --git a/packages/react/src/components/input.tsx b/packages/react/src/components/input.tsx new file mode 100644 index 0000000000..f8a61b0228 --- /dev/null +++ b/packages/react/src/components/input.tsx @@ -0,0 +1,48 @@ +type BaseProps = { + iconLeft?: React.ReactNode; + iconRight?: React.ReactNode; + error?: string; +} & Omit, "ref">; + +type Props = { + label?: string; + hint?: string; +} & BaseProps; + +const BaseInput = ({ + iconLeft, + iconRight, + error, + className, + ...props +}: BaseProps) => { + return ( + + ); +}; + +// this isn't used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export const Input = ({ label, hint, ...props }: Props) => { + if (label || hint || props.error) { + return ( + + ); + } + + return ; +}; diff --git a/packages/react/src/components/navigation.tsx b/packages/react/src/components/navigation.tsx new file mode 100644 index 0000000000..16f7b6d9fe --- /dev/null +++ b/packages/react/src/components/navigation.tsx @@ -0,0 +1,46 @@ +import { BackArrow, X } from "../icons/nav.js"; +import { Button } from "./button.js"; + +interface NavigationProps { + onBack?: () => void; + onClose: () => void; + showBack?: boolean; + showClose: boolean; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export const Navigation = ({ + showBack = false, + showClose, + onBack, + onClose, +}: NavigationProps) => { + return ( +
+ + + +
+ ); +}; diff --git a/packages/react/src/components/notification.tsx b/packages/react/src/components/notification.tsx new file mode 100644 index 0000000000..208ae50e56 --- /dev/null +++ b/packages/react/src/components/notification.tsx @@ -0,0 +1,29 @@ +type NotificationProps = { + type: "success" | "warning" | "error"; + message: string; + className?: string; +}; + +// this isn't used externally +// eslint-disable-next-line jsdoc/require-jsdoc +export function Notification({ className, type, message }: NotificationProps) { + const bgColor = (() => { + switch (type) { + case "success": + return "bg-bg-surface-success"; + case "warning": + return "bg-bg-surface-warning"; + case "error": + return "bg-bg-surface-error"; + } + })(); + return ( +
+ {message} +
+ ); +} diff --git a/packages/react/src/components/otp-input/otp-input.tsx b/packages/react/src/components/otp-input/otp-input.tsx new file mode 100644 index 0000000000..b4eaab5b09 --- /dev/null +++ b/packages/react/src/components/otp-input/otp-input.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useRef, useState } from "react"; +import { ls } from "../strings.js"; + +export type OTPCodeType = [string, string, string, string, string, string]; +export const initialOTPValue: OTPCodeType = ["", "", "", "", "", ""]; +const OTP_LENGTH = 6; +type OTPInputProps = { + errorText?: string; + value: OTPCodeType; + setValue: (otpCode: OTPCodeType) => void; + setErrorText: React.Dispatch>; + disabled?: boolean; + handleReset: () => void; + className?: string; + isVerified?: boolean; +}; + +export const isOTPCodeType = (arg: string[]): arg is OTPCodeType => { + return ( + Array.isArray(arg) && + arg.every((item: string) => typeof item === "string" && item !== "") && + arg.length === OTP_LENGTH + ); +}; + +export const OTPInput: React.FC = ({ + value, + setValue, + errorText, + disabled, + setErrorText, + handleReset, + className, + isVerified, +}) => { + const [autoComplete, setAutoComplete] = useState(""); + const [activeElement, setActiveElement] = useState(0); + + const refs = useRef>([]); + // Initialize refs + useEffect(() => { + refs.current = refs.current.slice(0, OTP_LENGTH); + refs.current[0]?.focus(); + }, []); + // Select active element when active element value changes + useEffect(() => { + if (activeElement !== null && refs.current[activeElement]) { + refs.current[activeElement]?.select(); + refs.current[activeElement]?.focus(); + } + }, [activeElement]); + + useEffect(() => { + const newValue = autoComplete.split(""); + if (isOTPCodeType(newValue)) { + setValue(newValue); + } + }, [autoComplete, setValue]); + + const handleChange = (e: React.ChangeEvent, i: number) => { + //Fix for ios chrome autocomplete + if (e.target.value.length === OTP_LENGTH) { + const chromeIOSAutocomplete = e.target.value.split(""); + if (isOTPCodeType(chromeIOSAutocomplete)) { + setValue(chromeIOSAutocomplete); + setActiveElement(null); + return; + } + } + const newValue = [...value] as OTPCodeType; + newValue.splice(i, 1, e.target.value); + setErrorText(""); + setValue(newValue); + focusNextElement(); + }; + + const handleClick = (i: number) => { + refs.current[i]?.select(); + setActiveElement(i); + setErrorText(""); + }; + + const focusNextElement = () => { + const nextElement = activeElement ? activeElement + 1 : 1; + setActiveElement(nextElement); + }; + + const focusPreviousElement = () => { + const previousElement = activeElement ? activeElement - 1 : 0; + setActiveElement(previousElement); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pasteData = e.clipboardData + .getData("text/plain") + .split("") + .slice(0, OTP_LENGTH); + if (isOTPCodeType(pasteData)) { + setValue(pasteData); + } else { + setErrorText(ls.error.otp.invalid); + } + }; + + const handleKeydown = (e: React.KeyboardEvent) => { + if (activeElement === null) return; + switch (e.key) { + case "Backspace": + e.preventDefault(); + const newValue = [...value] as OTPCodeType; + newValue.splice(activeElement, 1, ""); + setValue(newValue); + focusPreviousElement(); + break; + case "ArrowLeft": + e.preventDefault(); + focusPreviousElement(); + break; + case "ArrowRight": + e.preventDefault(); + if (activeElement < OTP_LENGTH - 1) focusNextElement(); + break; + case "Spacebar": { + break; + } + } + }; + + return ( +
+ {/* Input for autocomplete, visibility hidden */} + setAutoComplete(e.target.value)} + onClick={handleReset} + /> +
+ {initialOTPValue.map((_, i) => ( + (refs.current[i] = el)} + tabIndex={i + 1} + type="text" + aria-label={`One time password input for the ${i + 1} digit`} + inputMode="numeric" + pattern="[0-9]*" + //Fix for ios chrome autocomplete + maxLength={i === 0 ? OTP_LENGTH : 1} + onFocus={() => setActiveElement(i)} + onPaste={handlePaste} + onChange={(e) => handleChange(e, i)} + onClick={() => handleClick(i)} + onInput={focusNextElement} + onKeyDown={handleKeydown} + key={i} + disabled={disabled || isVerified} + value={value[i]} + aria-invalid={!!errorText} + /> + ))} +
+ {errorText && ( +

{errorText}

+ )} +
+ ); +}; diff --git a/packages/react/src/components/strings.ts b/packages/react/src/components/strings.ts new file mode 100644 index 0000000000..b37469d59d --- /dev/null +++ b/packages/react/src/components/strings.ts @@ -0,0 +1,112 @@ +const STRINGS = { + "en-US": { + login: { + tosPrefix: "By signing in, you agree to the", + tosLink: "Terms of Service", + email: { + placeholder: "Email", + button: "Continue", + }, + passkey: { + button: "I have a passkey", + }, + or: "or", + }, + addPasskey: { + title: "Add a passkey", + continue: "Continue", + skip: "Skip", + simplerLoginTitle: "Simpler login", + simplerLoginDescription: + "Create a passkey to enable quick and easy login with Face ID or Touch ID.", + enhancedSecurityTitle: "Enhanced security", + enhancedSecurityDescription: + "Prevent phishing and theft by registering a passkey with your device.", + }, + loadingEmail: { + title: "Check your email", + verificationSent: "We sent a verification link to", + emailNotReceived: "Didn't receive the email?", + resend: "Resend", + resent: "Done!", + }, + loadingOtp: { + title: "Enter verification code", + 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.", + }, + completingOtp: { + title: "Verifying...", + body: "It may take a moment to complete authentication.", + }, + loadingPasskey: { + title: "Continue with passkey", + body: "Follow the prompt to verify your passkey.", + supportText: "Having trouble?", + supportLink: "Contact support", + }, + protectedBy: { + title: "protected by", + }, + error: { + general: { + title: "Permission denied", + body: "The request is currently not allowed by the agent or the platform. Try again later.", + }, + connection: { + passkeyTitle: "Connection failed", + passkeyBody: + "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", + timedOutBody: "It looks like you need more time.", + }, + cta: { + tryAgain: "Try again", + useAnotherMethod: "Use another method", + skip: "Skip", + }, + customErrorMessages: { + eoa: { + walletConnect: { + chainIdNotFound: { + heading: "The connected wallet does not support this network", + body: "The wallet connection failed.", + tryAgainCTA: "Try again with a different wallet", + }, + walletConnectParamsNotFound: { + heading: "Couldn't connect to WalletConnect", + body: "The WalletConnect configuration is missing or incorrect.", + tryAgainCTA: "Try again", + }, + }, + default: { + heading: "Couldn't connect to ", + body: "The wallet's connection failed or was canceled.", + tryAgainCTA: "Try again", + }, + }, + }, + otp: { + invalid: "The code you entered is incorrect", + }, + }, + oauthContactSupport: { + title: "Need help?", + body: "Contact support", + }, + }, +}; + +export const ls = STRINGS["en-US"]; diff --git a/packages/react/src/hooks/internal/useIllustrationStyle.ts b/packages/react/src/hooks/internal/useIllustrationStyle.ts new file mode 100644 index 0000000000..49a6abaa6e --- /dev/null +++ b/packages/react/src/hooks/internal/useIllustrationStyle.ts @@ -0,0 +1,7 @@ +import { useUiConfig } from "../useUiConfig.js"; + +export function useIllustrationStyle() { + return useUiConfig(({ illustrationStyle }) => ({ + illustrationStyle: illustrationStyle ?? "outline", + })); +} diff --git a/packages/react/src/hooks/useAddPasskey.ts b/packages/react/src/hooks/useAddPasskey.ts new file mode 100644 index 0000000000..925a85a442 --- /dev/null +++ b/packages/react/src/hooks/useAddPasskey.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import type { UseMutationParameters, UseMutationReturnType } from "wagmi/query"; +import { useConfig } from "wagmi"; +import { + type AddPasskeyParameters, + type AddPasskeyReturnType, +} from "@alchemy/wagmi-core"; +import type { ConfigParameter } from "../types"; +import { + addPasskeyMutationOptions, + type AddPasskeyMutate, + type AddPasskeyMutateAsync, +} from "../query/addPasskey.js"; + +export type UseAddPasskeyParameters = ConfigParameter & { + mutation?: + | UseMutationParameters + | undefined; +}; + +export type UseAddPasskeyReturnType = UseMutationReturnType< + AddPasskeyReturnType, + Error, + AddPasskeyParameters +> & { + addPasskey: AddPasskeyMutate; + addPasskeyAsync: AddPasskeyMutateAsync; +}; + +/** + * React hook for adding a passkey to an already authenticated account. + * + * This hook uses the `addPasskey` mutation to add a passkey to the authenticated account. + * + * @param {UseAddPasskeyParameters} parameters - Configuration options for the hook + * @returns {UseAddPasskeyReturnType} TanStack Query mutation object + * + * @example + * ```tsx twoslash + * import { useAddPasskey } from "@alchemy/react"; + * + * function AddPasskeyForm() { + * const { addPasskey, isPending } = useAddPasskey(); + * } + * ``` + */ +export function useAddPasskey( + parameters: UseAddPasskeyParameters = {}, +): UseAddPasskeyReturnType { + const { mutation } = parameters; + const config = useConfig(parameters); + const mutationOptions = addPasskeyMutationOptions(config); + + const { mutate, mutateAsync, ...result } = useMutation({ + ...mutation, + ...mutationOptions, + }); + + return { + ...result, + addPasskey: mutate, + addPasskeyAsync: mutateAsync, + }; +} diff --git a/packages/react/src/hooks/useAuthConfig.ts b/packages/react/src/hooks/useAuthConfig.ts new file mode 100644 index 0000000000..33420a00b8 --- /dev/null +++ b/packages/react/src/hooks/useAuthConfig.ts @@ -0,0 +1,10 @@ +import { useUiConfig, type UiConfigStore } from "./useUiConfig.js"; + +// TODO: give this a selector param to prevent unnecessary re-renders +export function useAuthConfig(): NonNullable { + const { auth } = useUiConfig(); + if (!auth) { + throw new Error("Auth config should be present in UiConfig"); + } + return auth; +} diff --git a/packages/react/src/hooks/useAuthModal.ts b/packages/react/src/hooks/useAuthModal.ts new file mode 100644 index 0000000000..3e0a8c72c5 --- /dev/null +++ b/packages/react/src/hooks/useAuthModal.ts @@ -0,0 +1,42 @@ +import { useCallback } from "react"; +import { useUiConfig } from "./useUiConfig.js"; + +export type UseAuthModalResult = { + openAuthModal: () => void; + closeAuthModal: () => void; +}; + +/** + * A [hook](https://github.com/alchemyplatform/aa-sdk/blob/main/account-kit/react/src/hooks/useAuthModal.ts) that returns the open and close functions for the Auth Modal if uiConfig + * is enabled on the Account Provider + * + * @returns {UseAuthModalResult} an object containing methods for opening or closing the auth modal. [ref](https://github.com/alchemyplatform/aa-sdk/blob/main/account-kit/react/src/hooks/useAuthModal.ts#L4) + * + * @example + * ```tsx twoslash + * import React from 'react'; + * import { useAuthModal } from "@account-kit/react"; + * + * const ComponentWithAuthModal = () => { + * const { openAuthModal } = useAuthModal(); + * + * return ( + *
+ * + *
+ * ); + * }; + * ``` + */ +export const useAuthModal = () => { + const { isModalOpen, setModalOpen } = useUiConfig(); + + const openAuthModal = useCallback(() => setModalOpen(true), [setModalOpen]); + const closeAuthModal = useCallback(() => setModalOpen(false), [setModalOpen]); + + return { + isOpen: isModalOpen, + openAuthModal, + closeAuthModal, + }; +}; diff --git a/packages/react/src/hooks/useElementHeight.ts b/packages/react/src/hooks/useElementHeight.ts new file mode 100644 index 0000000000..b794d1a856 --- /dev/null +++ b/packages/react/src/hooks/useElementHeight.ts @@ -0,0 +1,27 @@ +"use client"; + +import { type MutableRefObject, useLayoutEffect, useState } from "react"; +import { useResizeObserver } from "./useResizeObserver.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export function useElementHeight( + target: MutableRefObject, +) { + const [height, setHeight] = useState(); + + useLayoutEffect(() => { + if (target.current) { + setHeight(target.current.getBoundingClientRect().height); + } + }, [target]); + + useResizeObserver({ + ref: target, + box: "border-box", + onResize: (size) => { + setHeight(size.height); + }, + }); + + return { height }; +} diff --git a/packages/react/src/hooks/useLoginWithPasskey.ts b/packages/react/src/hooks/useLoginWithPasskey.ts new file mode 100644 index 0000000000..562502ae50 --- /dev/null +++ b/packages/react/src/hooks/useLoginWithPasskey.ts @@ -0,0 +1,103 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import type { UseMutationParameters, UseMutationReturnType } from "wagmi/query"; +import { useConfig } from "wagmi"; +import { + type LoginWithPasskeyParameters, + type LoginWithPasskeyReturnType, +} from "@alchemy/wagmi-core"; +import type { ConfigParameter } from "../types"; +import { + loginWithPasskeyMutationOptions, + type LoginWithPasskeyMutate, + type LoginWithPasskeyMutateAsync, +} from "../query/loginWithPasskey.js"; + +export type UseLoginWithPasskeyParameters = ConfigParameter & { + mutation?: + | UseMutationParameters< + LoginWithPasskeyReturnType, + Error, + LoginWithPasskeyParameters + > + | undefined; +}; + +export type UseLoginWithPasskeyReturnType = UseMutationReturnType< + LoginWithPasskeyReturnType, + Error, + LoginWithPasskeyParameters +> & { + loginWithPasskey: LoginWithPasskeyMutate; + loginWithPasskeyAsync: LoginWithPasskeyMutateAsync; +}; + +/** + * React hook for Passkey authentication - initiates authentication flow with the specified options. + * + * This hook wraps the `loginWithPasskey` action with React Query mutation functionality, + * providing loading states, error handling, and mutation management for the OAuth authentication flow. + * + * @param {UseLoginWithPasskeyParameters} parameters - Configuration options for the hook + * @param {Config} [parameters.config] - Optional wagmi config override + * @param {UseMutationParameters} [parameters.mutation] - Optional React Query mutation configuration + * @returns {UseLoginWithPasskeyReturnType} TanStack Query mutation object with the following properties: + * - `loginWithPasskey`: `(variables: LoginWithPasskeyParameters, options?) => void` - Mutation function to initiate OAuth login + * - `loginWithPasskeyAsync`: `(variables: LoginWithPasskeyParameters, options?) => Promise` - Async mutation function that returns a promise + * - `data`: `LoginWithPasskeyReturnType | undefined` - The last successfully resolved data (void) + * - `error`: `Error | null` - The error object for the mutation, if an error was encountered + * - `isError`: `boolean` - True if the mutation is in an error state + * - `isIdle`: `boolean` - True if the mutation is in its initial idle state + * - `isPending`: `boolean` - True if the mutation is currently executing + * - `isSuccess`: `boolean` - True if the last mutation attempt was successful + * - `failureCount`: `number` - The failure count for the mutation + * - `failureReason`: `Error | null` - The failure reason for the mutation retry + * - `isPaused`: `boolean` - True if the mutation has been paused + * - `reset`: `() => void` - Function to reset the mutation to its initial state + * - `status`: `'idle' | 'pending' | 'error' | 'success'` - Current status of the mutation + * - `submittedAt`: `number` - Timestamp for when the mutation was submitted + * - `variables`: `LoginWithPasskeyParameters | undefined` - The variables object passed to the mutation + * + * @example + * ```tsx twoslash + * import { useLoginWithPasskey } from '@alchemy/react'; + * + * function LoginForm() { + * const { loginWithPasskey, isPending, error } = useLoginWithPasskey(); + * + * const handleGoogleLogin = () => { + * loginWithPasskey({ + * provider: 'google', + * mode: 'popup' // or 'redirect' + * }); + * }; + * + * return ( + * + * ); + * } + * ``` + */ +export function useLoginWithPasskey( + parameters: UseLoginWithPasskeyParameters = {}, +): UseLoginWithPasskeyReturnType { + const { mutation } = parameters; + + const config = useConfig(parameters); + + const mutationOptions = loginWithPasskeyMutationOptions(config); + + const { mutate, mutateAsync, ...result } = useMutation({ + ...mutation, + ...mutationOptions, + }); + + return { + ...result, + loginWithPasskey: mutate, + loginWithPasskeyAsync: mutateAsync, + }; +} diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts new file mode 100644 index 0000000000..b25ab2ed55 --- /dev/null +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -0,0 +1,106 @@ +"use client"; + +import type { RefObject } from "react"; +import { useEffect, useRef, useState } from "react"; + +type Size = { + width: number | undefined; + height: number | undefined; +}; + +type UseResizeObserverOptions = { + ref: RefObject; + onResize?: (size: Size) => void; + box?: "border-box" | "content-box" | "device-pixel-content-box"; +}; + +const initialSize: Size = { + width: undefined, + height: undefined, +}; + +// See: https://usehooks-ts.com/react-hook/use-resize-observer +// eslint-disable-next-line jsdoc/require-jsdoc +export function useResizeObserver( + options: UseResizeObserverOptions, +): Size { + const { ref, box = "content-box" } = options; + const [{ width, height }, setSize] = useState(initialSize); + const previousSize = useRef({ ...initialSize }); + const onResize = useRef<((size: Size) => void) | undefined>(undefined); + onResize.current = options.onResize; + + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + return () => setIsMounted(false); + }, []); + + useEffect(() => { + if (!ref.current) return; + + if (typeof window === "undefined" || !("ResizeObserver" in window)) return; + + const observer = new ResizeObserver(([entry]) => { + const boxProp = + box === "border-box" + ? "borderBoxSize" + : box === "device-pixel-content-box" + ? "devicePixelContentBoxSize" + : "contentBoxSize"; + + const newWidth = extractSize(entry, boxProp, "inlineSize"); + const newHeight = extractSize(entry, boxProp, "blockSize"); + + const hasChanged = + previousSize.current.width !== newWidth || + previousSize.current.height !== newHeight; + + if (hasChanged) { + const newSize: Size = { width: newWidth, height: newHeight }; + previousSize.current.width = newWidth; + previousSize.current.height = newHeight; + + if (onResize.current) { + onResize.current(newSize); + } else { + if (isMounted) { + setSize(newSize); + } + } + } + }); + + observer.observe(ref.current, { box }); + + return () => { + observer.disconnect(); + }; + }, [box, ref, isMounted]); + + return { width, height }; +} + +type BoxSizesKey = keyof Pick< + ResizeObserverEntry, + "borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize" +>; + +function extractSize( + entry: ResizeObserverEntry, + box: BoxSizesKey, + sizeType: keyof ResizeObserverSize, +): number | undefined { + if (!entry[box]) { + if (box === "contentBoxSize") { + return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"]; + } + return undefined; + } + + return Array.isArray(entry[box]) + ? entry[box][0][sizeType] + : // @ts-ignore Support Firefox's non-standard behavior + (entry[box][sizeType] as number); +} diff --git a/packages/react/src/hooks/useUiConfig.tsx b/packages/react/src/hooks/useUiConfig.tsx new file mode 100644 index 0000000000..036a9c6e2b --- /dev/null +++ b/packages/react/src/hooks/useUiConfig.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type PropsWithChildren, +} from "react"; +import type { + AlchemyAccountsUIConfig, + AuthIllustrationStyle, +} from "../types.js"; + +type AlchemyAccountsUIConfigWithDefaults = Omit< + Required, + "auth" +> & { + auth: NonNullable>; +}; + +export type UiConfigStore = AlchemyAccountsUIConfig & { + isModalOpen: boolean; + setModalOpen: (isOpen: boolean) => void; +}; + +export const DEFAULT_UI_CONFIG: AlchemyAccountsUIConfigWithDefaults = { + illustrationStyle: "flat" as AuthIllustrationStyle, + auth: { + addPasskeyOnSignup: false, + header: null, + hideError: false, + sections: [[{ type: "email" }], [{ type: "passkey" }]], + onAuthSuccess: () => {}, + hideSignInText: false, + }, + modalBaseClassName: "", + supportUrl: "", + uiMode: "modal", +}; + +const UiConfigContext = createContext(undefined); + +// TODO: use the selector param to prevent unnecessary re-renders +export function useUiConfig( + selector?: (state: UiConfigStore) => T, +): T; + +export function useUiConfig(): UiConfigStore { + const store = useContext(UiConfigContext); + + if (!store) { + throw new Error("useUiConfig must be called within a UiConfigProvider"); + } + + return store; +} + +export function UiConfigProvider({ + children, + initialConfig, +}: PropsWithChildren<{ initialConfig?: AlchemyAccountsUIConfig }>) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const value = useMemo( + () => ({ + ...initialConfig, + isModalOpen, + setModalOpen: setIsModalOpen, + }), + [initialConfig, isModalOpen], + ); + + return ( + + {children} + + ); +} diff --git a/packages/react/src/icons/EOAConnectionFailed.tsx b/packages/react/src/icons/EOAConnectionFailed.tsx new file mode 100644 index 0000000000..ace4625631 --- /dev/null +++ b/packages/react/src/icons/EOAConnectionFailed.tsx @@ -0,0 +1,60 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../hooks/internal/useIllustrationStyle.js"; + +const Ring = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + const isRingGrey = + illustrationStyle === "filled" || illustrationStyle === "flat"; + + return ( + + + + ); +}; + +const Cross = () => ( + + + + +); + +export const EOAConnectionFailed = { + Ring, + Cross, +}; diff --git a/packages/react/src/icons/alchemy.tsx b/packages/react/src/icons/alchemy.tsx new file mode 100644 index 0000000000..541d08d671 --- /dev/null +++ b/packages/react/src/icons/alchemy.tsx @@ -0,0 +1,57 @@ +import type { SVGProps } from "react"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const AlchemyLogo = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + + + + + +); diff --git a/packages/react/src/icons/auth-icons/apple.tsx b/packages/react/src/icons/auth-icons/apple.tsx new file mode 100644 index 0000000000..bb2e4e13c7 --- /dev/null +++ b/packages/react/src/icons/auth-icons/apple.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from "react"; + +export const AppleIcon = ( + props: JSX.IntrinsicAttributes & SVGProps, +) => { + return ( + + + + ); +}; diff --git a/packages/react/src/icons/auth-icons/discord.tsx b/packages/react/src/icons/auth-icons/discord.tsx new file mode 100644 index 0000000000..31482bbda9 --- /dev/null +++ b/packages/react/src/icons/auth-icons/discord.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from "react"; + +export const DiscordIcon = ({ + fill, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); diff --git a/packages/react/src/icons/auth-icons/facebook.tsx b/packages/react/src/icons/auth-icons/facebook.tsx new file mode 100644 index 0000000000..cb63a2a9df --- /dev/null +++ b/packages/react/src/icons/auth-icons/facebook.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export const FacebookIcon = ( + props: JSX.IntrinsicAttributes & SVGProps, +) => { + return ( + + + + + + + ); +}; diff --git a/packages/react/src/icons/auth-icons/google.tsx b/packages/react/src/icons/auth-icons/google.tsx new file mode 100644 index 0000000000..259a38c7c9 --- /dev/null +++ b/packages/react/src/icons/auth-icons/google.tsx @@ -0,0 +1,39 @@ +import type { SVGProps } from "react"; + +export const GoogleIcon = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + return ( + + + + + + + ); +}; diff --git a/packages/react/src/icons/auth-icons/index.ts b/packages/react/src/icons/auth-icons/index.ts new file mode 100644 index 0000000000..5790138152 --- /dev/null +++ b/packages/react/src/icons/auth-icons/index.ts @@ -0,0 +1,7 @@ +import { DiscordIcon } from "./discord.js"; +import { FacebookIcon } from "./facebook.js"; +import { GoogleIcon } from "./google.js"; +import { AppleIcon } from "./apple.js"; +import { TwitchIcon } from "./twitch.js"; + +export { DiscordIcon, GoogleIcon, FacebookIcon, AppleIcon, TwitchIcon }; diff --git a/packages/react/src/icons/auth-icons/twitch.tsx b/packages/react/src/icons/auth-icons/twitch.tsx new file mode 100644 index 0000000000..cc1ed916a5 --- /dev/null +++ b/packages/react/src/icons/auth-icons/twitch.tsx @@ -0,0 +1,37 @@ +import type { SVGProps } from "react"; + +export const TwitchIcon = ( + props: JSX.IntrinsicAttributes & SVGProps, +) => { + return ( + + + + + + ); +}; diff --git a/packages/react/src/icons/chevron.tsx b/packages/react/src/icons/chevron.tsx new file mode 100644 index 0000000000..68a2dda07a --- /dev/null +++ b/packages/react/src/icons/chevron.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from "react"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const ChevronRight = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); diff --git a/packages/react/src/icons/coinbaseWallet.tsx b/packages/react/src/icons/coinbaseWallet.tsx new file mode 100644 index 0000000000..e896dd282c --- /dev/null +++ b/packages/react/src/icons/coinbaseWallet.tsx @@ -0,0 +1,38 @@ +export const CoinbaseWallet = ({ + className, + ...props +}: React.JSX.IntrinsicAttributes & React.SVGProps) => ( +
+ + + + + + + + + + + +
+); diff --git a/packages/react/src/icons/connectionFailed.tsx b/packages/react/src/icons/connectionFailed.tsx new file mode 100644 index 0000000000..d345a549b4 --- /dev/null +++ b/packages/react/src/icons/connectionFailed.tsx @@ -0,0 +1,144 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../hooks/internal/useIllustrationStyle.js"; + +export const ConnectionFailed = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + + )} + {illustrationStyle === "filled" && ( + + )} + {illustrationStyle === "linear" && ( + + )} + {illustrationStyle === "flat" && ( + + )} + + ); +}; + +const ConnectionFailedOutline = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const ConnectionFailedFilled = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); +const ConnectionFailedLinear = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const ConnectionFailedFlat = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); diff --git a/packages/react/src/icons/illustrations/add-passkey.tsx b/packages/react/src/icons/illustrations/add-passkey.tsx new file mode 100644 index 0000000000..bbc635944a --- /dev/null +++ b/packages/react/src/icons/illustrations/add-passkey.tsx @@ -0,0 +1,319 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const AddPasskeyIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const AddPasskeyOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const AddPasskeyLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + +); + +const AddPasskeyFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + +); + +const AddPasskeyFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + +); + +const AddPasskeyOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const AddPasskeyLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + +); + +const AddPasskeyFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + +); + +const AddPasskeyFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + +); diff --git a/packages/react/src/icons/illustrations/added-passkey.tsx b/packages/react/src/icons/illustrations/added-passkey.tsx new file mode 100644 index 0000000000..3f8e027b7c --- /dev/null +++ b/packages/react/src/icons/illustrations/added-passkey.tsx @@ -0,0 +1,377 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const AddedPasskeyIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const AddedPasskeyOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const AddedPasskeyLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + + +); + +const AddedPasskeyFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const AddedPasskeyFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + + +); + +const AddedPasskeyOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const AddedPasskeyLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + + +); + +const AddedPasskeyFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const AddedPasskeyFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + + + +); diff --git a/packages/react/src/icons/illustrations/email.tsx b/packages/react/src/icons/illustrations/email.tsx new file mode 100644 index 0000000000..9ac1b09f3f --- /dev/null +++ b/packages/react/src/icons/illustrations/email.tsx @@ -0,0 +1,371 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const EmailIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const EmailOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + +); + +const EmailLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const EmailFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const EmailFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const EmailOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + +); + +const EmailLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const EmailFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const EmailFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); diff --git a/packages/react/src/icons/illustrations/passkey.tsx b/packages/react/src/icons/illustrations/passkey.tsx new file mode 100644 index 0000000000..afb8ffa001 --- /dev/null +++ b/packages/react/src/icons/illustrations/passkey.tsx @@ -0,0 +1,271 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const PasskeyIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const PasskeyOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const PasskeyFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const PasskeyOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); + +const PasskeyFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + + +); diff --git a/packages/react/src/icons/illustrations/passkeys.tsx b/packages/react/src/icons/illustrations/passkeys.tsx new file mode 100644 index 0000000000..b011623061 --- /dev/null +++ b/packages/react/src/icons/illustrations/passkeys.tsx @@ -0,0 +1,496 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const PasskeySmileyIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const PasskeyShieldIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const PasskeySmileyOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const PasskeySmileyOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeySmileyFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const PasskeyShieldOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const PasskeyShieldOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const PasskeyShieldFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); diff --git a/packages/react/src/icons/illustrations/success.tsx b/packages/react/src/icons/illustrations/success.tsx new file mode 100644 index 0000000000..938ad8daa8 --- /dev/null +++ b/packages/react/src/icons/illustrations/success.tsx @@ -0,0 +1,265 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../../hooks/internal/useIllustrationStyle.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const SuccessIllustration = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + <> + + + + )} + {illustrationStyle === "linear" && ( + <> + + + + )} + {illustrationStyle === "filled" && ( + <> + + + + )} + {illustrationStyle === "flat" && ( + <> + + + + )} + + ); +}; + +const SuccessOutlineLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const SuccessLinearLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const SuccessFilledLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const SuccessFlatLight = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const SuccessOutlineDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); + +const SuccessLinearDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const SuccessFilledDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +const SuccessFlatDark = ({ + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + +); diff --git a/packages/react/src/icons/mail.tsx b/packages/react/src/icons/mail.tsx new file mode 100644 index 0000000000..610f835b72 --- /dev/null +++ b/packages/react/src/icons/mail.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from "react"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const MailIcon = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); diff --git a/packages/react/src/icons/metamask.tsx b/packages/react/src/icons/metamask.tsx new file mode 100644 index 0000000000..6b0c8839c5 --- /dev/null +++ b/packages/react/src/icons/metamask.tsx @@ -0,0 +1,138 @@ +export const MetaMask = ({ + className, + ...props +}: React.JSX.IntrinsicAttributes & React.SVGProps) => { + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/packages/react/src/icons/nav.tsx b/packages/react/src/icons/nav.tsx new file mode 100644 index 0000000000..d3257e3f77 --- /dev/null +++ b/packages/react/src/icons/nav.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from "react"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export const BackArrow = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); + +// eslint-disable-next-line jsdoc/require-jsdoc +export const X = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); diff --git a/packages/react/src/icons/oauth.tsx b/packages/react/src/icons/oauth.tsx new file mode 100644 index 0000000000..cc22c2d693 --- /dev/null +++ b/packages/react/src/icons/oauth.tsx @@ -0,0 +1,69 @@ +import type { KnownAuthProvider } from "@account-kit/signer"; +import { Spinner } from "./spinner.js"; +import { GoogleIcon, FacebookIcon, TwitchIcon } from "./auth-icons/index.js"; + +interface ContinueWithOAuthProps { + provider: KnownAuthProvider; +} + +interface OAuthConnectionFailedWithProps { + provider: KnownAuthProvider; + logoUrl?: string; + logoUrlDark?: string; + auth0Connection?: string; +} + +// TO DO: extend for BYO auth provider +export function ContinueWithOAuth({ provider }: ContinueWithOAuthProps) { + return ( +
+ + {(provider === "google" && ) || + (provider === "facebook" && ) || + (provider === "twitch" && )} +
+ ); +} + +// TO DO: extend for BYO auth provider +export function OAuthConnectionFailed({ + provider, + logoUrl, + logoUrlDark, + auth0Connection, +}: OAuthConnectionFailedWithProps) { + return ( +
+
+ + + + + +
+ {(provider === "google" && ) || + (provider === "facebook" && ) || + (provider === "auth0" && logoUrl && ( + <> + {auth0Connection} + {auth0Connection} + + ))} +
+ ); +} diff --git a/packages/react/src/icons/passkey.tsx b/packages/react/src/icons/passkey.tsx new file mode 100644 index 0000000000..d072813835 --- /dev/null +++ b/packages/react/src/icons/passkey.tsx @@ -0,0 +1,31 @@ +import type { SVGProps } from "react"; +import { Spinner } from "./spinner.js"; +import { PasskeyIllustration } from "./illustrations/passkey.js"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export function LoadingPasskey() { + return ( +
+ + +
+ ); +} + +// eslint-disable-next-line jsdoc/require-jsdoc +export const PasskeyIcon = ({ + fill = "currentColor", + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); diff --git a/packages/react/src/icons/spinner.tsx b/packages/react/src/icons/spinner.tsx new file mode 100644 index 0000000000..abe51c2f8d --- /dev/null +++ b/packages/react/src/icons/spinner.tsx @@ -0,0 +1,24 @@ +// This spinner icon has to be in png format because svg does not support angular gradients +const SpinnerLightModeBase64 = + "data:image/png;base64,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"; + +const SpinnerDarkModeBase64 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJwAAACcCAYAAACKuMJNAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAADCjSURBVHgB7X0JkBzXed7/XvfM3rgWBAhAEEkAS1IExQsL8BApAiApyhRtR46hVKrsRK5KFDmlcspVTspxJSWqUpVIcSkpOYotx7mcRJaLjMNYIqkLF0kYIk6KoMBrcZACCUJYXIs9Z6b7/f6P97p7FiAJgDj26I8cTF/Tszvz7fef7z0DJQRPvnhwVpepzDON4VkIlZkGoTUy6SywUSugawUXGQduFhpnLVh0zhlLWwzaOQ3WIqRuACI6hukpRHsKDIw5SI9gHcc+de+Nh6EEfSTTDP9j08HWni43r2FhHjqYD+gfxtFnETF5DPOI9uSzIZbRM/rPydIWnadnJhw9gTHWMO0Sh0JAQ8f5tUJF2uf70UVGX23epfc61UgbB1tt6+H7Vl1zAKYZpjzhmGBL5lavSuqjPcSi+QZwsVECZL+7kMIKewwTho85BCVUuI4IJQRCOu/0HMOYcJ+INvm1fMLJ//wKvpG1fIbv7a/VSwwyFQEPEFMPp5i+8kBvz36Y4piShGOSXds5uizF6vKYlIx+y1bn5JSqllccVqmMUEHRmAOeEYiBVvoqVTcG8QhU6fgYs4lIRbxF2aAbK9nyl+r70HHnj2TmmGxvfp0do/ffT2Z8b23s9IFP33PzCZhimDKEe+aZvpbWubCMNpeTpHzE0Jfv/O8npo6I4Zx+6SD7JvzuQgYhJF2DYv+Q+BIZ2XegJpYVjo0iH/VkCxDSWH4N81EuFubKLZXD8r7yPoWfCbForotqqc/o8ADdeMfqVUt3wBTBpCfcppf6PoK1eCl5UcvJP2olN4x5Yvz37r9c8F8uf/XoFSycd54U/B85/sgvVjKywqGImFzi1TEnHOuVEZZZISQG5ULnTWfuw6mpFj7qffTnAecJJ38UqPcm6gYlJi7zeTMGJtmLMT77wG03vAOTGJOWcM/uevNjqUlvMiksRig68hZFUxxmpOMv0zkXFIRUixz8SK+3gRTB9zKRJ5vz3AkKpyTOieJVToJSbxatVfIS4VQwWShtrqr55234DyBXzlzdnAQforFNahdeS38Th1Nnn31g5ZLtMAkxqQgnZnMe3kaO0m0kDK3Mj5T+s4alSaRCzR6ZOFQTJ2a0YKoCMtLptieccZkfF6JUvgLPVDjw/psGo6J2xui18kpROznHZEfMSeMDjiLBgqkG9fBMTjaXqbG8lglKJ4W66E6Spv5gbe/122ASYVIQjonWeVV0a2LdbaQMLSZ8acF0oqYh1GdybDWJcOrER+qcB0c+i04LkSpq8GB98JCb1JDeUB+vSIxChIvOK6hGsRKpYlAja1ya6nEfCWeqiv7vA8J9nf/jQEm0OAwnnaVt/VMyNv+D8H4ivdkJNPH31/ZeNymIN+EJt3H3gVtJvlYRK1p4XzyiQDj+vDkDYVQJxH3zaQ1OwBpvrhyoytmCX6dulXz5xBnnZYTNacovhpDi0ESvN6kAeR4uRKkYEsDGm1FVOMhycv51LhBTEiQFc+nfWhXONJlSpi5CUySNLvf5nJJb5deYE43Eff+hu3pegAmMCUu4DVtfX0Qf7b1RXJnrv1xWIv12ovw6612poHLiuEMhccsUyUiW+1CFL/2M9AhKmkPvKfZLXTBQrmRmzjSpG8gb+z+Agt9ljBKSzb9LNGow6C/N1S38XPyPJ10evfqgJviFSjb//oXPTLhJxKNb/tXaO5a8BBMQE45wW17r72oMD32CvvUlCX2gyq3IK8q4CkAwqwwhCF9vVeUiPdicHgEfCeaEc7nygMlyZ6pwwaRmPlwhaOAfgdWO3UUhskMlrc3TIiYjEKdNcnXzZJN7yD9iKl1GNtnz74Nexawk+jQQEoVlshmfiGYKQ2agjc86b3Ou9ekH7/zIcZhAmFCEe3bPoY9Dkq5Mk0Yrkqkz4tP4zL/3q+RvPVW1Qvm8nY2YkF55TCFH5rJo1VNVAgpXMLPNCWD6Uk8lieuHKBowjcZApaUy1jAwcNqYAb7us7dfd+psP/cmqsMODw2aSrU6y8a21dbsbFM1s4n5s501s+ldF4b3Cf6bVY4JaQFy3874NAn7bRwpczxk+Df1XxWKW6fEyg5qaGMQxyklqR2RlEjX8xOYIJgQhNuy5bWupK26OkVcFJITfDw42voBoxTN5bBR5fOZjiaFs4WoLqghfeg2BBVRpEleIuogyeChNKr/3CTJ0UOjnQO/tea6MbhE+NGW1xbGbfECErsllEdZZDwJNT0yPpiQKMdyGlB/DzXBGMwputxk62/r1VE/L5Zdjj6c1s/of+xzadufTwS1u+KEe/6n+69PUryHPpsW0TPwGbGiX9X0IWv6QfyySE2rKaqfZ1ZIWSjEb6pDGu0DM/r2m0Nt+y4luc4Fz+95a3aSJEshwaVEo6X0G8wJZINxuTc2yWQ/kdUtmNPgq2L2u4dgJgPbWYNZYhmO095Tq++4smp3xQj3zDPPtFS7e+4g0/Jx3peUKyjRmHiczkg1a2uClKEk19Bm9UzxiSDzvHQ/8kQVx6sOEO+1mO6/f9WyQzCBsWFn31L6VVfS77DMYDQn5N18ricjjqRcfKhgvP8myefQVOCRBUXGqyj4oANhY5c99b3e3t4RuAK4IoRjE9pob/kMpumM7KD1gQCIa9P8l+6CqSj4c2ZcFOdCwpb/0qO3oVp9YfSd0f5HHumpwSTDhh2vryK1WkkWtEeqDi5rBZAAoqhuEihgnnIBm7sUrJi+POYDeHk9k/e4te7ra3pvOAaXGZedcM/tfHMBmYQH0aQtIbdlChGdZMHOlrLwZjWS8DMvRWV5uSgaSxvJnsYJeHEykuxs2Lr10Jyxav3TmLoeMqndctDYTNmaItPsjxIzD4NDXtStTB15X4NtGIkS/J/339PzIlxGXFbCkdm4w2J0u39nzsKHWHGcE6wqF3y5rA7JpjWKNNelCRI2oTXAxktjR81PpwrRxoOJl1Qay1LAR4g13cHPhWK5rUg4NZ/oiZWb4tDDF/r5OKAA9921K3u+C5cJl41wz+98485aam6OLHv6aUHBjObMfKrD/1A+lxb5Xf/DZmkSvgfUKHWwp9afvjRViXY2bN6x75foI7uLCNSdk0jyK1nAhRAiK6NplKxmoZUKn+njF4aqCZPur+Ey4LIQbv22A/dRWq2HfTHJrwm1Ut6V0pLspa7JtOY/oZagxCexmiawDnaMHHN7phPRili/7dVua6ur6S9wre+/y9QN8wg3sxhKLXU90GR+nQktzZq/s3+zduXS/w6XGJeUcHv37q0eGWl5gP7IrpY3M8Xuh5B0TbOe/6z0FAIIJhuZXSstR/wp2XerXbWN99544yCUEOJFtvLrRJ1bg+9WTK2IyPk2qLSpNxB9R4PP8wWfGN2hGXbga5cygr1khGOy/WKk+mn65bqlVMTRo6Q1nNVAS2qIlOmMROrzonserYa/UGOjehzj1ntvXfI6lDgD67f13R1Z8xn6UOcWolJorik31Wk12NByGif45M9fgloAIl37V3t7F14S0l0SwjHZ+ofbHk7BSWTFbhsJmWFHzdeTTEY6cFlxXUIA3xjpfzjyOaI3x46/uvmRRx6ZlubzXCFqB5VH6SO9GzUF3uT7aq7Op1F83TZErfKFZF0t8rqfz7CnvnoplO6SEG7Trv2PUga9O3R1OKm7p6CN//rXR0V2aNCJyO9B1pCohXeKZutJ3e1+6J6lL0OJc8aPt7/+oLXxo1RDbg+jzvJ0HHrXzuZJJfANAz6CRe0kJbcO31i7quffwUXGRSfcxp/s+wT9wMuKLUSh4A6Z0o3353wPmTev9FsPna4lT//qvaWvdiFYv+3tbmNqv0f0mWuL/Uucu8Msh1moSnCHDRa6ZRRkmbc8sGrJn8FFhIWLiI3bXr8VY1xCfy6OC3/8kBMJ8IA7dGnhja3FphcT2VK6gD6PvtH+N54syXbh4CL9sD35b4hKW3nfdxcbzFqrmgbqGE3S+dRJASQK967f3vcbcBFx0RTuuV0HbiE+3cL+Ao8zCN24xf59hpRHRek0n5bn3siXS+DFB+7u2Q0lLho27jjwK+TO/Gqmar48UWxA9WY0jGzLVKjQR/jk6hXLnoSLgIuicM9u37eYshe38HYKuYxFQc9TfYRheXIOtEilKujAxva5kmwXH2tXLvkuOcrf0T2LzWQzIUgoQhpUsxYxVkZnfo1HycFFwIcm3KZNezvJEN4t1AHISMR+Z6qjm1B2yIRyu1cwpSjn6XAEdYPxM/ffvrQPSlwSrLlj6foUom8Sg0Zt01eOoaMkN7fsy+W5vAxp2vjd53b3XQUfEh+KcOQbVNL2lgfpL6iiP1RKwSmgduNG4sa5zDmVMX2qaKnvurGmXmupfn/NquuOQIlLiodWXvtiYuDf07cxyqqWKVvzEEoZvOak3dM09RmSX96eov2DH/7wpQ74EPhQhNu0bf8tlci0B7PIiMAHC1wh4KizAFY6zWhzJ6Gr11urP/j0zYun3PwZExUP9S79Of2tf81aM1z02Tyy7UAKjmiztnXuBkhhXjyn/dfgQ+CCCffj519fArG5IfHkYpNJSdtgVlHdtlRMp1hUCKYUuFOfyNZWku0KgEnXINIR1zipm5XBpA4roWuhXZ3hZybQZLKc/6WN2/vuhwvEBRHupZeOdESt9mbpeeZaqHZ1ACZppmhMOiag2FUyr4F8fGWMuKUk25WDKF0j/U9+15gwD48HyvDIbAC5N6vI43v0+zXwmxfqz10Q4U4lQ3fST9guQz7450s90TzxQmCQpjJsD1MpwEfID/ob2THR272nAx66+/pX6Xv6M83H+aZ9LTH4yQYAwpx58kDjq0SSo+9IEvvbcAE4b8I9u+216yi9MS8kP5hc3OOGBdOa+ltHYkFz0tHxPWt6r3sNSkwIrF6x5HkqAT0pLUoGCsMMXfO8dYxs2oswqgxv2rhj/yNwnjgvwv2QTKmDCk+LxeGAqhhAZipFwTglIlWGFGXQC1UddECLOfDgiiV7oMSEwtpV1/8/qvBskcHbGR284vlGOsTmPkXU+ak4zfV3n9v97nmZ1vMiXFwfWU5OW/vZzmleDULzbtMWmnho5sqpM6neVMOsqPN/k9PWL5UInZ7AhAo3K5otxLI8Z562MqF1KXYm6fDn4TxwzoTbsPXQIoPptbztuFYqAQG6oHQgyoYOfQJYUiDk0zk09e5q2/peYxpQYkKCe99GR92/pXTJCGRz06KQzTVXvMWb00307MGVz+1+66Zzfa9zJpyp1G8Njj/vh2YQJl5T0ddXGSSgkJn66i/feuvVw1BiQuORT/b0Ryb6K6l7IehwRO4WNjoQR/0jlIqEjnMVx08CjiRtfP5c3+ecCLdh94FrqGLQro5/yuEnhMhTErysdhGGkNmEgMKYyoG1K28su3QnCe5bcd0PiEK7IG8kCdZLlM0GN06GMpmsLk4ac93Gnfs/cy7vcU6Ei1InkhmCgrAtuTXaZ/L5N8YsoLBmqFFp+xmUmFTogvY/ISaN+Ib0rMaKMsW2pkhCx7DxzcJWOrfNunMpe30g4VjdiD8doVLAPR4Z8XyUoEqn28GHqyeNvQ+XpnTSgf05h+5/8ZfYVPoqFPR1KGLewOnkdNpZmdv1gSr3gYRjdeOARN5Ty6QYTGsgGu8bE0nTJR9LXPrOw3feeBBKTEqsXdXzLCnWq1nS16ivpmNRwrRn3rfTeftCvfUznDp7v3u/L+E27H7lGooy22RHTKn3ziLpisfQ+4Yy2XxqAvlc29hlnT6gxMVHFLk/DvpmxzUrYRgLps4V7+szus5qbejR97vv+xLOYOVG3fKZNR+hinm1wZcjVYt4AKCaWVOJ3nj41ltLUzrJ8ck7evpJx56QTmwsDNn0GRMdRK3zRWi0GqaYMBdGuC0793+U8n9t0tbmkyCpLysI2VLltPpsSsg4qgw3+gf3QYkpgVpl8BntKuGxxKFVSUwsou8ukQulAVIHXBMnOjZs27f2ve75noRLAJfysw6G4TlCKamLPvUhjZRYyL9F4t9RPuathx8u1W2qgC0V6c3TOqjVhKBUJuqDUNNHnRasOJcdldbXvNc9z0q4H+/cP5PYNYOTay6VG2TPEqASopivlCYk9PmQkQfv6nkFSkwpjLWMPE3qdsaAaOmdQ50oR+aMxHyKW9q+edOOfTef7X5nJVw1jpboVsKj5tVX889YqCow+WxI+NZtSbYpCFE5Gz2FYYA+Yj4tbmhPl9VxFGHKV4pd7zrb/c5KuLRRW0Dq5QkmeoY+tyt+HFc9ErK5aUL7CV/khme2tPZDiSmJsXjwaWtwWCZqMlmoIC2a1uj0YEJIl9tdg27t2VIkZxDu2e3vLKbDcfPRhNdME3CezfdZCvFE+dAcu1STn5S48pCsA5qnMvkyWvZikjUV920QPAkmOiq14TNU7gzCJTC6GDErYaFXOowgDIhJuPuN+yzlwYo3I25/FUpMadRbhp+SCa+8mkFWOgfflwl+JiaZsl+GP5AqnhGtNhHuuzsPt9vIzIHCYGbp+MAoJHk9CZP8Bhb6S3Wb+mCVIzXbG/Zx/KwNIUmsyTLx81LEj483q02Ea3Ej3TwFg5XRV4VhfjL2L8qSu2RWHT9Y/VJjyvEJ0wRElr8ECJNrZg8IOTlTWJiOrSsv1zTerDYRropmvpAt0ZYjqZfyvohbCvn0WzxdakrOYzLKI4CgxLRAvdrJ9XHJswaCyRhp40d4FYZVu2yYITxQvEcT4RJw3ZLojbySMemIfCjpD5u3IUkymK+plJHpNAJ3/xC9NsoCib5kz8TSheiUSg7zadhkII5zS4tmNSMcz6BIl8QF902IJaKWBKL5pZZRy1kpVkpzOs1AIeM259vQc3I5nTtYJ6wuTAMmBa+O1sbwkvD6wujXeL7lEVbW1w6ypkqveEmKMt8RJUyiOKK6qRmeaEsjlrj0IBfqZQpWh3XYoK796nMk2YxM+fRsKEV9Ch7uDq/PCGdj7FIXLVGRs1qyKj5jJAVTeZCklmSbpiAl2yaBginOshSmc80Sc9nIQuLjmQpHxnKOrkkaZ127jKjpnTwBuckySkvCTVMYtDzv8hnRqj9rdBC1gTDohjZuCX6cEG7rzw7NsQhhltTQVifPyVmOMVrmtJaEm6aot3TIEpg6YXVIjeQpEj2n41mNH/XVloxI95EQbmBwSFb1C0MAGTLPm0WXDQfkW0aBdDhwz+LFo1BiWkKjVTiaV7o078b2j4cVSn01+HR+yGGKTsyqEK69rW22ptqkB4kzv066QPwEgtZ393Ic4Vf7Ow0lpjVSBz/LivfYpHSMJtXjfbo+V7iGg1ZLkWgi6Q4qWyVcuvL1ez9jg/XE4+ckTsqptqY7jN3jCVVM+Pp6atNxWQPG+oZeYZVxja5QSJCifRQpVXlQVuIDh9j/Qzu1MSyntJ/moJTFfl4tzk/Jn1GraR457WOyfpVWVbgtr73WJS3ikUyBpOMXONFLj4jrp1njZeQ74xAfuaunNKnTHPHw0FHUJVn95Dea+JV1g+WZiVdIm5DurX/5wHw7dCKpSKYtKd6NH1HIyPFUln6BjxSSuhuCEtMea9YsJx6YX4SVIFG7fg1kExxanaEafGcJHa+MuqW2s9rWmXDyIwoKBtlz5LtFosJ8cA5qZXRaQkAytp+HEWr7OS8RD9lgaR7h5WReQD/Mi49F8fx41CVxDDFqhphUzSQmkplpslm4sjfgw1FbW9n7VkJALDqqoxmcLyvoYoWh2gUAfoi0FCBMmqZXWzKSbVSyx8TbVIuRY5Mq+z5QDedkAq7GaKlwJQQO0l/ws2+69LzKFroxksoNA6hZ5yLsjKuupZUPRDy3uCcYsoUV1iXGxrEzCQWyfDqJTWRtSbgSisQMYwWagwMMISqAH/eQHeeVwf3QGG0IESFL8ugBxdTycyLn+NnE1QRKlGBE0RHQsakKnfuXt4rLoRvQCarZyHZYKhu0Sq5XXs/E88MDI8yRHaPkcNdHS4UrITBmlE2q8StLB3JpjODJhmEfJLXbFTOveLHJQDoFR6kxqVkidOXjluqqlAQ2UKLEWaClVPAJ4MLEciabBp3TxMYmpHA6IEbW9fDJX66bJia0j0QyyyX5fHRuzXVmDEqUIKxZtfxImEEOtWhvfH2VBzqY8YNQSefm87BmHqtgWMGCwjEBpfLgwcd0v3mxthIlwnBBC/kI/GzcKurofN8BzP/amFMeVFRAIRuGQDWB0FOX+hhXiMmTDpYoMQ7eT8vMqn/OqwyQtQKbmH01TfkyEuGhBBBOVtQ0lp5TmaEpQYtxqXAlmqCZ3pB3U2BgXeg3l1SJpOcwbgoVpIiV5msw+DJDFBHRkjIbUuJMNI3AxzDHUkHlhHwZ+4xYUIxz34yb4rKVgD1k7Az9ayApTWqJZmC+XkOe/sBslsxgVwPBLBo3KuoVHqAEjPxDZrb0x20UuU2bDrZCiRKETdsPXi0bJsz3qzN1aXjKJlS7RjIY+IXOYxnFWSqEt5MCI6PIhxHCxwbpXBVKlAjQoS+GF7yUUpbxDZkuL2pxjkQSwUJFB7bBROIHp0P4GTy55IZZakT3ky7XAiVKAOfbkgWag8N8xJYXOGvDmFTN0+m+eTeuUu7X+bXqmYKicES2OI4BCyyV43xNYtikDkCJaQ+0UWc2I6Ff8hLVYfMdIv66rLjP3SMRZJWDpkRvAbzPflzM090k9QhKlCC4NF2AMoAB8zqq730Dn+8t/ke6d8TWGzx3Qw5x9XypK4CPV1t4aaMIW7qgNKklBGQ7F4St/CnvHglE9M985AjlctuaaqNB0aI45qnxMdRTvUsHYw1TEq5EwNV+KgeTFxSyp6aR+EI6dEPWnhiuZXM8+AdfkDSo6EUkiytc1uJ1GWI5FzdsO5QowbBmgfEjUI2fTwSCadWiqs0dOTCRgT5bvyYeYjLxMVW1oHAFH67CRxrq11loeWzTphhKTHtQ0fMGCCtFg067ilgo4OtVXICQ9vPGWDJsH+npqcnhkPpo6HYwoYnsM/vUxHLx6+45c0qzOs2xftv+6/2MIlmbr8zra/K5RmQlpEJr3IP3fewNSQMn9TE/bysPXIilY0TIFRfNbCLnWe2qrVe1QYlpDRtBj1Aqq4OabI4RXTkk0DFUV1EW/dPZgONoWMxqI4zOkkalTOX0WJylS+KBxgwoMc2Bd4QBz1hYyFeK9n4gjQQU+QyERwA84ayLh5ls6AMDfdZpHdS/U+bJWZK4Bo51QYlpDefwBkR3RjMHGlOc3sGgT7BF1u7mZyHcqEtGXMS5N/bhlFxC1krhRoQGnWfS2WpcLQOH6Yvndr65gOTsemtCjFokntPZk/yy5ZnyIfbxWSHcL925LPPh8oi0XswHo0atsRzllMn9XQtnQYlpiQTSO3TL97lpF5KfajVbV7CJiJ/s7ckVjviXOpsM6otJKB06TZF40+pNbd6sGUM9xdKsTlOQnew1YQigjjn1kWmIEqyPJfwpA7vDazOXrq01GtahqBylqrXEer5GapokRkgoC4ags2lUKtx0BdoVxV1T6PpFv3R0OC6kTF22LH3eHRc1Tqp+6UwiomgV3WfXzkZ5fdUkxkTW2C1bXitVbpph4659KygUWDiuLs+JXytkC0uSe4jqoXk22w8bn7jhhpGKcUlQMTnY4LJWjH6cTd5Bouk6bHR1zoQS0wrGmV/OqgjedZNt1Kp9CFLRE492B9fetWxXeH1GOPbj0lo6AiH3xhUGLTDkTXH+WCwnKohjw92PP45lu9I0gkNYkVXoUVeaAchULuSAs+fU4YvF1zeNje6odB4LJBOi+TJXPK43riGsbHBuxV61vL+sOkwTbNzR9ytEuUWmMCQQiqO2oJl0ukQDPlu8RxPh3lq16BRzKfYUi31RPys4VPhcBYnlFE8w2+n8wOmrocS0AFnBX7ahYOVLVr6q4FMikI9IBe2MS+vpruI9mgj3OU6PmMZgA8J4hoYQjkmmDyZasepA17Tajscff7w0q1McP9r984XErV5Kmvm1UrU7xPrlxvka7grBMEWXjqTZ/al7bzxcvI8989ZdxwPBVMWUZDwhIe8H9WO5C/7dVdeuvApKTGnESf2LGhOE1G0YGshwEo3aYg8mcGbDfm/8fc4g3Im3Fp1q2LE0MQ15JRNMBgY2tKzFZpWzJVkwwaUwk84tVW7qYsPW1xfR00oqoIJOlZ8DIZ/FnIUvtCfR8aHVK5Z8MOE+9zkyu43oBCsbk43Nq1OHTluXCsEDm1gXVzBNEzvno71zocSUhLG2lwzlQt8VIskOyYFw8T6sBm3CKtAy5J67ezef7V72bAfbuuqnspSbJ5oqHGDN5+jEn2vUs9dUKnZOqXJTFNb8Nvh+joKkZWNQpZ/XFJMhFlzF/MVZb3W2g2uWLx+qJPUhsZ2SAdFAIdDLZWyscDYOK/Q8RjnjmYvu6IYSUwobt/f9U+LVQuEUhiGBRstZYf5oJh8vSx66ew2+sfbWJa+f7X72vd6oRu4cqxiPh2azyQ8+HgjG2+zb1et1ISJvt7bG3aXKTR2I72bsr6JfSpzHPMusIbwNsiCDzeb3DekSK71v336ve74n4T59z80nqH5KNXsNHoDMZ8X7bnX/cCJylcyvS9OGLVVu6sBW498gWi3ypNJMrm8jt4VhgEZL70ZMq3Pv3n/Hku++5z3f7w3HxtJjURS7ijefTDB+sJplebhCEMGIIpz7+N695Yw3kxysbvRl/4PiQrum0BeCGFbT0qOS9dUp9L/1fvd9X8Kd+Pl1x9LYuWAyE1+ZdQWCBbhYzS9vd9faFkCJyY1K9Pv8lKXWbOh/88GDr53qI6iefWftyp6/fr/bvi/hOEXixqJjsVe2yDlpUZJIgX25Cvt2IA+2seTOQUVMrG3/8c79ZSfJJMWmnQf/Dj09EGZFyoMBfWAY6FxI8so6IJB+64PubT/oghM/33nM2jgN+2I+iVhxXMWxsbpsc6xKPBMCchAhZjeqzvP1tRKTCBt+eoh8NvelkO0QI6nrt4Ffvz50h0Dx+6WU8LsfpG4A50C4z33uc2mjffRo0U9jRWNiBeh2VZ45iOCQIk0aduNLb5SmdZLB1se+hOAWoS4nk3fuGk37MmOwWd78TEnxH5/T/c/logc/9rHjxGWZ9IaVLSTkWNEY1WqIWHNSss9nGtj51PN7ZkOJSYH12/b9QyLaZyUGkOjT6vwgPkBg6Eh6LjIo8XRqB3N47crrPlDdGOds8mZCx7tRVHGsXsF8huChGER4704L/xTfxlF7986dOytQYkKDTWlkzZcglEKhMDm0V7ugeFbbLXX+N86PYPK1c32fcyZcb+/CEbKTQ0wmKMzzqya0Ck0m19XEp5M3sHUzGHUvKv25iYsXXuibYeq1/0PCMdMzzYQJGmRUlrQc+VH248wsHfj/a1fdsOFc3+u8SNBhlx6xSZpKdYEeWMjFsfIxGZ3MJlyFWo2UkHhYqVTR1lxlw7aDZQvTBMWotX9AhYJFftfPfIRhQDMW5k71uV9JhTDfDmMjPSffLeC8CNfbaxoz27r72VeL4tixzlU82XyhC0LFtVKtCvkaDWM4c2zitPMHW382B0pMKGzcue93iDm/7ge85HkOXdrZp0Cypkpfu9eUL33z//mBe254B84D523mbrll1skEkhFjGoarqOzIsa/GRIviCnGsiqxsUNPr2bTyMdmutsze8lp/ObRwgmDD9jd+i76p3ykeC6TzbeO6XpEcQFPMxtGXvuOBO5c9CeeJC/KrZsPouzbh3FwVGlTbaGnRAKF4DataIB23ArRCC13jsDZ0at6mg1guLnKFsXnHgYeMsf8K8zGkmbrl09yDjvfzpAtD/+jxDrS4fwkXgAsiXG9vb6MenzxaEXVDGVSTmdKKHhOytWgEW3VV79tRkJGSCh4+OH9vWW+9Yli/u+8mZ/APw2IdAM05tXCdtcXqqZa0ZCeCbz5w2/mZ0uyecIF4qLd3ACvuZNhns1mr5eeJY1hMl4zRf9WWFiVdp7UnR+HqknSXH+tf6LvJpvY79IXNNFmX7hmXBSUzPjGSjW4m7fvzB1acvykN+FCpivtuX3Y8hUi6MzU3p74aFIjH6paRzc+X7kYQa7bNHBtuKZXuMoKVzcZENoQZfsZKga/FW8CmMaZyiv9Bb2Ipkn1nLE2/CR8CH4pwJLHuzejY20mqRX3KhYjSsbrxbiCbYExVDrIHQBJbezSpztt66FA5mPoSY/22vruj1PwlsSxrqtDpKnXFcBn+1/ySJt+OrNWgi6u/+chdPafhQ8DARcDWrYfaTkNtEQmc+Gt8LBCNzapECK2tomyBbI4Uj4+z3R2l57bOxsl7b7xxEEpcdGzY2bfOOPN1NNmwePRrSqIfIs/9QOG48ctQ6nJYYRVUNF9cs2rpeviQuCjZ/3vuWUycGTvB23WqLITjVSFVq1DMjYwiKPOEbDCqlGSyVYl06el4Fme8ocRFxeYd+3/Xovk6mOZ2IoEpdO1my082QxiJ8EcXg2z6lhcR2159u3vsdCKksaZmhFhjYxRAtODY2JgnoKoaX5P6Z9fSSgQk6rW1QXw6Hb733o8OsLmGEheMF144PmMkOvEYfcXr/KrMxc8TsydWM6dLT2qKRHt5fUGBX/SNB1ct/QZcJFxUwjGe2913FUaVTiYYo1ogG+9zeoQdtqBsfGyssM25OrSVJBp96/iaNWvKdc8vAM9u37c4NeYJ4s5H5IBPo+lZE2ypP2VQFz0VIYD8Gp4SP/q/a1cu+RdwEXHRC+qfvKOn3zRGhG0h9xayvC2ebKxsGcFayJxyLq+1VR5yfTWOoOPauT986UgHlDgvbNj++j9ODf6IZGuxb5PUE74iarLiu8lXYSvMNm5MGPpnXr3YZNP3vQTgzpC/efHQ1Y1aWmX9CsSDtlbZblI2MqccWKjqjWRmttUp+dLBk6P9q28b5Il2oMR7gk3osD3+DWuih9EnPDDkOaR7Mlv+Fv14ZlmlGXU/qB94gu5tT/Hv3/UhI9Kz4ZK0DLH/tf72xUcaLVGd/biWgjl148yoJIdHRpRsQ4jRGP36I3ofIV/HrNar3nh3NkfCUOKs2LR9398btid2GhN9WufLCuUDoz6a9Yt05CFqaB5vysR5XDKy6c90CfEYKd1du96a3wlpJfhsgWh1qsGK0rVx6qSVzOowRpbnC6M0CqnbCLGOly1MXRudG6IPpgOiloFa/22l2gWwr5YY+CNizt2avIDMPZMgwJfd5eKs6mP8+WzC8ODj8ct/0pbiFy4V2fx7XHqs3/Z2N0a1Dla3VopE3fAIUubaJNUWxwSLxkZN2tpGRHMcYBjX6rCFiMavTdvYx3PYSdtD/n7VeMZI/97NozzeAqYheERchPgF2vznmHVE+tyZ55UnXJ7LzeMEJpvTSkNmSdmsPrGmd9nvwSXGZSEc44W+4zMGTp6cmQULPkAAUrJALo5Qw/WDpHjtpHFMOBgm4hEz22h7dJQnIybpjFvTejUa+xXuRJ4mEKKl6T+hqvoXiD4zM5OIPgr1K13p1TrGDxFh3HW6lYOTcP9tzcqlX4HLgMtGOMYLfX0zRkfaZnDOTQg3olwRX629EChwIpjIJao2zOk5h0yy0cgYknxRPL6uPe3AwyP9acto5+jQ0IvJVFU8JlqcwhfRkqpxaUqGGEATkfI0R54CyXJromgegXRGt+ij/o8P3tnzH+Ay4bISjvH441vbZi2cM7uzs8MMOQ0Y2tvbM3Ub8YoXlI3B6sbPbWJeO2hbK2DtHR1CxNPkccyt1tPjx4eST33qltGpkjQWogF8kejyRfqlZ0Bwy4Kv5ns4vK75sS4YzGsoS3nIFZiNMbUw4FL8R0S2n8BlxGUnHIMH8W/e8cpVjTiSKFmVbUQCBN7X6JQIRiQc8o4bk42329qVcEzC9o5OBCLbaRiAjrQLk05vkqsdyTzory9fvrwOkwyeZB+nz+j3SZo+EXw059MZmeOVO/p5ZDDeZMq8gYF0QRIdzzpzqIG47uFVyw7BZcYVGUnFa0I8+8wTR83xyjCTjQMFkJhUg4TIjhoxpxnZOpA329rZtOqjC7pgZHjICNk6u9ijgeGhQf0DOvpOfHi0tW3T3qOdBw8ebN00CVY+3LztwH0btu//auTwZWLIU3ToXq2t+6F5UmxyOtBAFU4oh2Fmo3CjMOhF1S70sYUX8aSo/7U9nfWpK0E2+fHgCoPza4Mdta5qLcrIX22hRAhv0D+dnUo8PhbOB3VjwiVkU4vq1kXnuCu0aybtHwdIZqXYoGu6Zs92R4eH00MDA+7dp55KH3vssStqdp988eCsWc59hhKR9xFxHiVGzMhTFCb3ytTXwoI6aZ1TEfrDpevaaGZNbzPOvNJrT8cW/vX9K3oehyuIK044xuMkajP2vj0zHmpUQBy3jiwqDdekhQiWfbdByRQNwli94WbOnAkJ+3mnTkFX1wwhXCPR62cQ4eAY3bVrFhHsF1BrXKXE7K65/h0jDtbdlK7TSO2SEZDJNRPxGkzhFjDpfcbZ++iT/yiA1ygwwfLpz1AkXlaa8tsSCLhQgspTHVAsL2QzU4bsx09I4v/Z/VdI1YqYEIQLYLWrVaOOamXENvluoP5cmnbIB5lQoBBHQ4ZVjomY+KAiPDOYcDNmzcLjx44Dz5DYaKRYn9ONtYbDhcDzoaT4NhyCq2oL5DXz5o24vXsBliwZxRUrVujULc3pg7zOOA7ff/Hgtfzc0oBrIkPEMkD6itcaJpXDW5Gfi/5Vfp/ApizazIpLeWRZSGmEaEHndc68t/H3y9Ik5G8AfH3tymX/BSYIJhThGDxla+dHb+/orPIQHEXqydblu+UGTw8Cm1LeZv+tSDTeHq9ujdlzcA4dO0IK100Kdxh0rYq59flYW5DgaJLI9UP1OoIn3eDgCoTVAP30xbECfoWyVF/+sq6wzZKxacd+59MO3l+CoEZat9Qfx1vJYBbzCoB3w5yKWlAyE6LPnEBB7cJ8uhnBsNjykUev4b0sfD9yZEIngKoVMeGmX+BcGrcxd7csG6gTc1jpRqksEUXDhomWnkRUss3UYGEgDxaKxBMQ2Y7z89F+OHHiOF0zPzvFZAvbbW/Hhsm2HJhsN8nxN7p2Gdi8Obv+ppueCM2KmdIF0cnER3H2P+Km7kcjPM1ZpBM1F1QQAMarKRbzbuATHPkxEy6Bn9HTZ9f2Lvv8RCMbY8JGb8uXG05pHN+06WBrHNu22igHFYOcjaL/Z8KJkydxgMjG/psEDZ5sXaRkJ8JN5gJ0H9OJ2OeQOT1y5Bdw/Gr6ThoL6VqfI36TCP2RBDv7qgauWea/5BX03y5YsXq17D/xBJhXXnklKJRekqUlTEHcGCJ6oU07S/2b/GyorftjITEG4CeI8a/P4oZs2vDCe/gQ1Erk6gfznaIX/+Galcv+FCYwJpxJfS/09fW1vD0Ut6X1sSiYUyYZjwhJvFnlecEST7gZ5Ogx4RppMKcMNansv/Feraam9CNEuL4+gGuuqVPp7CYyp+Gr3Qz9TDpi3Lp165p8uk0796cmDyG19cLPiwDZOAE4i/MvZjk3xQDFICA3qRpqevZCQd3y4EGfYIA2vpUa86cP9S4dgAmOSUO4gGeIePMGbOtoNbbjgwVOhZw4rr7bcVK2GeS7sTmVc12zHXtucz3ZQsDA6iYXFAgHpG6Dg4MZuVavXu3Yh3vssdw537hjH3ryaCZCo0Mo+FbNvhho02MwyXhGPdPPza29axq+YghUhH0YwgTQ7twBuuGfTBaiBUw6wgUw8Waks6qN0aHobOrGvpsQjtz+etKNQd34teMj1KBw8+Ytc4FsXV1dhgOH1avVq4dxUWsWNISqkiw1JVwR+2jymgD40VJ+r9DV0ZTa8AWFPPb0JryZePQmWwym31l75/V/AZMQk5ZwARzVti65twXHTkfz48gEwjU82RhFwtXnk8J5V7oYoV7DESrcBKOju5AX+Lyeo1RCfz9/8WpSC2kR3Lh9X0HBClGqN4dNkSrvW39NYe7GzGSGqFOp6fILfDkL2D+TpYSeXtO7dAtMYkx6whXBM20ehoWVOUQ+Jlzl5AnTRQlfjlAHyaQCpUNCKqTl3dgI4V5NsKeHVW8Z7t37iqZEVhDZNvMdN7M5DSpEqZGvmC9/+ctCvI2kcMHU6burOSwU2LNZYhwUFK6YHtFCKeYTFuG4e5nn6dS3Kbx5ejKZzffDlCJcAJWt7KOPPhq9PNgdLZxzOrqa0iEcocLV5MvNmeNY4ZhsIUJl/43BJjUPGBgUNPRrpLpuXWZShU4UNDiTsctTRE2iJv1zpz4nnPFjpKAwYWgxaYt4inb2WDDfTiw8NVVIVsSUJNx4sNldvnx5VK1WTX9/qxX/bcECJdyoT/peE5K+HKVulmOsbk9QhKopkS9L0KBBpJGgISdc8MvCO2pMoCoGWl6C5gRwuJAezxFz98TWfu/k6YGXP7vm9lMwhTEtCDcefr5hs2vXLsv+2uKjMy0TbglFqLsoaLi+YFJV4ZrTIkwkDhp8N23u4ENWbspUK1c4PudeMhb20N5LDt2e05XKns/eft2UJth4TPi2nUuBQqG+uOCJYR+NlewLK4hi/WDWrVvtS1rrsPBaDNdnyV/Et6zhoZ3I5DlFF73JiVgL+Cafc1H00to7lrwFJeBvAaCHjhUm02WnAAAAAElFTkSuQmCC"; + +// eslint-disable-next-line jsdoc/require-jsdoc +export function Spinner({ className }: { className?: string }) { + return ( + <> + Loading + Loading + + ); +} diff --git a/packages/react/src/icons/threeStars.tsx b/packages/react/src/icons/threeStars.tsx new file mode 100644 index 0000000000..7d855ea0b6 --- /dev/null +++ b/packages/react/src/icons/threeStars.tsx @@ -0,0 +1,30 @@ +export function ThreeStarsIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/packages/react/src/icons/timeout.tsx b/packages/react/src/icons/timeout.tsx new file mode 100644 index 0000000000..33463cc728 --- /dev/null +++ b/packages/react/src/icons/timeout.tsx @@ -0,0 +1,181 @@ +import { type SVGProps } from "react"; +import { useIllustrationStyle } from "../hooks/internal/useIllustrationStyle.js"; + +export const Timeout = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => { + const { illustrationStyle } = useIllustrationStyle(); + + return ( + <> + {illustrationStyle === "outline" && ( + + )} + {illustrationStyle === "filled" && ( + + )} + {illustrationStyle === "linear" && ( + + )} + {illustrationStyle === "flat" && ( + + )} + + ); +}; +const TimedOutIconOutline = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); + +const TimedOutIconFilled = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); +const TimedOutIconLinear = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + + +); + +const TimedOutIconFlat = ({ + className, + ...props +}: JSX.IntrinsicAttributes & SVGProps) => ( + + + + + +); diff --git a/packages/react/src/icons/wallet.tsx b/packages/react/src/icons/wallet.tsx new file mode 100644 index 0000000000..fbf1c84c80 --- /dev/null +++ b/packages/react/src/icons/wallet.tsx @@ -0,0 +1,20 @@ +const SvgComponent = ({ + fill = "currentColor", + ...props +}: React.JSX.IntrinsicAttributes & React.SVGProps) => ( + + + +); +export { SvgComponent as WalletIcon }; diff --git a/packages/react/src/icons/walletConnect.tsx b/packages/react/src/icons/walletConnect.tsx new file mode 100644 index 0000000000..9e155e67a5 --- /dev/null +++ b/packages/react/src/icons/walletConnect.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; + +const SvgComponent = ({ + className, + ...props +}: React.JSX.IntrinsicAttributes & React.SVGProps) => ( +
+ + + + +
+); +export { SvgComponent as WalletConnectIcon }; diff --git a/packages/react/src/icons/walletConnectIcon.tsx b/packages/react/src/icons/walletConnectIcon.tsx new file mode 100644 index 0000000000..cb0a134bb9 --- /dev/null +++ b/packages/react/src/icons/walletConnectIcon.tsx @@ -0,0 +1,22 @@ +export const WalletConnectIcon = ({ + className, + ...props +}: React.JSX.IntrinsicAttributes & React.SVGProps) => { + return ( +
+ + + +
+ ); +}; diff --git a/packages/react/src/icons/warning.tsx b/packages/react/src/icons/warning.tsx new file mode 100644 index 0000000000..65c5f8e2a4 --- /dev/null +++ b/packages/react/src/icons/warning.tsx @@ -0,0 +1,18 @@ +export const Warning = () => { + return ( + + + + ); +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4fbd954f9b..6b424cb83a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,12 +10,18 @@ export type * from "./hooks/useSubmitOtpCode.js"; export { useLoginWithOauth } from "./hooks/useLoginWithOauth.js"; export type * from "./hooks/useLoginWithOauth.js"; +export { useLoginWithPasskey } from "./hooks/useLoginWithPasskey.js"; +export type * from "./hooks/useLoginWithPasskey.js"; + export { useHandleOauthRedirect } from "./hooks/useHandleOauthRedirect.js"; export type * from "./hooks/useHandleOauthRedirect.js"; export { useSendVerificationCode } from "./hooks/useSendVerificationCode.js"; export type * from "./hooks/useSendVerificationCode.js"; +export { useAddPasskey } from "./hooks/useAddPasskey.js"; +export type * from "./hooks/useAddPasskey.js"; + export { useUpdateEmail } from "./hooks/useUpdateEmail.js"; export type * from "./hooks/useUpdateEmail.js"; @@ -42,3 +48,14 @@ export type * from "./hooks/useSendPreparedCalls.js"; export { useAuthMethods } from "./hooks/useAuthMethods.js"; export type * from "./hooks/useAuthMethods.js"; + +export { useAuthModal } from "./hooks/useAuthModal.js"; +export type * from "./hooks/useAuthModal.js"; + +export { AlchemyUiProvider } from "./components/AlchemyUiProvider.js"; +export type * from "./components/AlchemyUiProvider.js"; + +export { AuthModal } from "./components/auth/modal.js"; +export { AuthCard } from "./components/auth/card/auth-card.js"; + +export { type AlchemyAccountsUIConfig } from "./types.js"; diff --git a/packages/react/src/query/addPasskey.ts b/packages/react/src/query/addPasskey.ts new file mode 100644 index 0000000000..c5ae564576 --- /dev/null +++ b/packages/react/src/query/addPasskey.ts @@ -0,0 +1,34 @@ +import { + addPasskey, + type AddPasskeyParameters, + type AddPasskeyReturnType, +} from "@alchemy/wagmi-core"; +import type { MutateOptions, MutationOptions } from "@tanstack/react-query"; +import type { Config } from "wagmi"; + +export type AddPasskeyMutate = ( + variables: AddPasskeyParameters, + options?: + | MutateOptions + | undefined, +) => void; + +export type AddPasskeyMutateAsync = ( + variables: AddPasskeyParameters, + options?: + | MutateOptions + | undefined, +) => Promise; + +export function addPasskeyMutationOptions(config: Config) { + return { + mutationKey: ["addPasskey"], + mutationFn: (variables: AddPasskeyParameters) => { + return addPasskey(config, variables); + }, + } as const satisfies MutationOptions< + AddPasskeyReturnType, + Error, + AddPasskeyParameters + >; +} diff --git a/packages/react/src/query/loginWithPasskey.ts b/packages/react/src/query/loginWithPasskey.ts new file mode 100644 index 0000000000..877e244a87 --- /dev/null +++ b/packages/react/src/query/loginWithPasskey.ts @@ -0,0 +1,42 @@ +import { + loginWithPasskey, + type LoginWithPasskeyParameters, + type LoginWithPasskeyReturnType, +} from "@alchemy/wagmi-core"; +import type { MutateOptions, MutationOptions } from "@tanstack/react-query"; +import type { Config } from "wagmi"; + +export type LoginWithPasskeyMutate = ( + variables: LoginWithPasskeyParameters, + options?: + | MutateOptions< + LoginWithPasskeyReturnType, + Error, + LoginWithPasskeyParameters + > + | undefined, +) => void; + +export type LoginWithPasskeyMutateAsync = ( + variables: LoginWithPasskeyParameters, + options?: + | MutateOptions< + LoginWithPasskeyReturnType, + Error, + LoginWithPasskeyParameters + > + | undefined, +) => Promise; + +export function loginWithPasskeyMutationOptions(config: Config) { + return { + mutationKey: ["loginWithPasskey"], + mutationFn: (variables: LoginWithPasskeyParameters) => { + return loginWithPasskey(config, variables); + }, + } as const satisfies MutationOptions< + LoginWithPasskeyReturnType, + Error, + LoginWithPasskeyParameters + >; +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 1ca07078af..7f69781d77 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -5,11 +5,57 @@ import type { UseQueryOptions, } from "@tanstack/react-query"; import type { Compute, ExactPartial, Omit } from "@wagmi/core/internal"; +import type { ReactNode } from "react"; +import type { AuthType } from "./components/auth/types"; export type ConfigParameter = { config?: AlchemyConfig | undefined; }; +export type AlchemyAccountsUIConfig = { + auth?: { + /** + * If this is true, then auth components will prompt users to add + * a passkey after signing in for the first time + */ + addPasskeyOnSignup?: boolean; + header?: ReactNode; + /** + * If hideError is true, then the auth component will not + * render the global error component + */ + hideError?: boolean; + onAuthSuccess?: () => void; + /** + * Each section can contain multiple auth types which will be grouped together + * and separated by an OR divider + */ + sections: AuthType[][]; + /** + * Whether to show the "Sign in" header text in the first auth step + */ + hideSignInText?: boolean; + }; + illustrationStyle?: "outline" | "linear" | "filled" | "flat" | undefined; + /** + * This class name will be applied to any modals that are rendered + */ + modalBaseClassName?: string; + /** + * This is the URL that will be used to link to the support page + */ + supportUrl?: string | undefined; + /** + * Set to "embedded" if the auth component will be rendered within a parent + * component in your UI. The default "modal" should be used if the auth component will be rendered in a modal overlay. + */ + uiMode?: "modal" | "embedded"; +}; + +export type AuthIllustrationStyle = NonNullable< + AlchemyAccountsUIConfig["illustrationStyle"] +>; + // From wagmi. export type QueryParameter< queryFnData = unknown, diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts new file mode 100644 index 0000000000..2fa4a999a3 --- /dev/null +++ b/packages/react/src/utils.ts @@ -0,0 +1,10 @@ +export function capitalize(str: string) { + return str + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function assertNever(_: never, message: string): never { + throw new Error(message); +} diff --git a/packages/wagmi-core/src/actions/addPasskey.ts b/packages/wagmi-core/src/actions/addPasskey.ts new file mode 100644 index 0000000000..bc9dbb7ab4 --- /dev/null +++ b/packages/wagmi-core/src/actions/addPasskey.ts @@ -0,0 +1,28 @@ +import { type Config } from "@wagmi/core"; +import { resolveAlchemyAuthConnector } from "../utils/resolveAuthConnector.js"; +import { assertAuthSession } from "../utils/assertAuthSession.js"; +import type { CredentialCreationOptionOverrides } from "@alchemy/auth"; + +export type AddPasskeyParameters = + | CredentialCreationOptionOverrides + | undefined; + +export type AddPasskeyReturnType = void; + +/** + * Adds a passkey to the authenticated user's account. + * + * @param {Config} config - The shared Wagmi/Alchemy config + * @param {AddPasskeyParameters} parameters - The parameters for the passkey creation + * @returns {Promise} Promise that resolves when the passkey is added + */ +export async function addPasskey( + config: Config, + parameters: AddPasskeyParameters, +): Promise { + const { connector } = resolveAlchemyAuthConnector(config); + + const authSession = connector.getAuthSession(); + assertAuthSession(authSession, "addPasskey"); + await authSession.addPasskey(parameters); +} diff --git a/packages/wagmi-core/src/actions/loginWithPasskey.ts b/packages/wagmi-core/src/actions/loginWithPasskey.ts new file mode 100644 index 0000000000..73aea69fdc --- /dev/null +++ b/packages/wagmi-core/src/actions/loginWithPasskey.ts @@ -0,0 +1,26 @@ +import { type Config } from "@wagmi/core"; +import { resolveAlchemyAuthConnector } from "../utils/resolveAuthConnector.js"; +import type { LoginWithPasskeyParams } from "@alchemy/auth"; + +export type LoginWithPasskeyParameters = LoginWithPasskeyParams; +export type LoginWithPasskeyReturnType = void; + +/** + * Initiates Passkey authentication flow with the specified parameters. + * + * @param {Config} config - The shared Wagmi/Alchemy config + * @param {LoginWithPasskeyParameters} parameters - Passkey authentication parameters + * @returns {Promise} Promise that resolves when authentication completes and connection is established + */ +export async function loginWithPasskey( + config: Config, + parameters: LoginWithPasskeyParameters, +): Promise { + const { connector, connectAlchemyAuth } = resolveAlchemyAuthConnector(config); + const authClient = connector.getAuthClient(); + + const authSession = await authClient.loginWithPasskey(parameters); + + connector.setAuthSession(authSession); + await connectAlchemyAuth(); +} diff --git a/packages/wagmi-core/src/index.ts b/packages/wagmi-core/src/index.ts index c03774839e..c47cfe1500 100644 --- a/packages/wagmi-core/src/index.ts +++ b/packages/wagmi-core/src/index.ts @@ -22,6 +22,12 @@ export { type LoginWithOauthReturnType, } from "./actions/loginWithOauth.js"; +export { + loginWithPasskey, + type LoginWithPasskeyParameters, + type LoginWithPasskeyReturnType, +} from "./actions/loginWithPasskey.js"; + export { handleOauthRedirect, type HandleOauthRedirectParameters, @@ -34,6 +40,12 @@ export { type SendVerificationCodeReturnType, } from "./actions/sendVerificationCode.js"; +export { + addPasskey, + type AddPasskeyParameters, + type AddPasskeyReturnType, +} from "./actions/addPasskey.js"; + export { updateEmail, type UpdateEmailParameters, diff --git a/yarn.lock b/yarn.lock index 112d3dd96a..791fb86adf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9135,11 +9135,6 @@ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.7.tgz#2863f2aa89e023592b981204ef92c5221b286410" integrity sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw== -"@types/react-dom@^19.1.9": - version "19.1.9" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.9.tgz#5ab695fce1e804184767932365ae6569c11b4b4b" - integrity sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ== - "@types/react-syntax-highlighter@^15.5.13": version "15.5.13" resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz#c5baf62a3219b3bf28d39cfea55d0a49a263d1f2" @@ -9169,13 +9164,6 @@ dependencies: csstype "^3.0.2" -"@types/react@^19.1.13": - version "19.1.13" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.13.tgz#fc650ffa680d739a25a530f5d7ebe00cdd771883" - integrity sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ== - dependencies: - csstype "^3.0.2" - "@types/semver@^7.3.12": version "7.7.0" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e"