diff --git a/apps/react-example/src/App.tsx b/apps/react-example/src/App.tsx index ebd18cac..079f284c 100644 --- a/apps/react-example/src/App.tsx +++ b/apps/react-example/src/App.tsx @@ -1,12 +1,14 @@ +import { useSendMagicLink, useVerifyMagicLink } from '@zerodev/wallet-react' import { AuthFlow, + type AuthFlowRenderArgs, Button, type PendingRequest, type Request, SignatureRequest, usePendingRequest, } from '@zerodev/wallet-react-kit' -import { useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { encodeFunctionData, erc20Abi, parseEther } from 'viem' import { useAccount, @@ -19,6 +21,135 @@ import { } from 'wagmi' type SignatureRequestMode = 'default' | 'children' | 'controlled' +type AuthFlowMode = 'default' | 'children' + +const AUTH_MODE_STORAGE_KEY = 'react-example:authMode' + +function readPersistedAuthMode(): AuthFlowMode { + if (typeof window === 'undefined') return 'default' + const value = window.localStorage.getItem(AUTH_MODE_STORAGE_KEY) + return value === 'children' ? 'children' : 'default' +} + +function getCodeFromUrl(): string | null { + if (typeof window === 'undefined') return null + return new URLSearchParams(window.location.search).get('code') +} + +function stripCodeFromUrl(): void { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + if (!url.searchParams.has('code')) return + url.searchParams.delete('code') + window.history.replaceState({}, '', url.toString()) +} + +// Module-scope guard: survives React 18 strict-mode remounts of the component, +// which a useRef inside the component would not. +let didVerifyForThisLoad = false + +function CustomAuthUI({ auth }: { auth: AuthFlowRenderArgs }) { + const { + step, + email, + otpId, + otpEncryptionTargetBundle, + config, + goToStep, + setEmail, + setOtpSession, + clearOtpSession, + } = auth + const [emailInput, setEmailInput] = useState('') + const { mutateAsync: sendMagicLink, isPending: isSending } = + useSendMagicLink() + const { + mutate: verifyMagicLink, + isPending: isVerifying, + error: verifyError, + } = useVerifyMagicLink({ + mutation: { + onSuccess: () => { + clearOtpSession() + stripCodeFromUrl() + goToStep('authenticated') + config?.onSuccess?.() + }, + onError: (err) => config?.onError?.(err), + }, + }) + + useEffect(() => { + if (step !== 'verifying-otp' || didVerifyForThisLoad) return + const code = getCodeFromUrl() + if (!code || !otpId || !otpEncryptionTargetBundle) return + didVerifyForThisLoad = true + verifyMagicLink({ otpId, code, otpEncryptionTargetBundle }) + }, [step, otpId, otpEncryptionTargetBundle, verifyMagicLink]) + + async function handleSend() { + if (!emailInput.trim() || !config) return + const result = await sendMagicLink({ + email: emailInput, + redirectURL: config.magicLinkBaseUrl, + }) + setEmail(emailInput) + setOtpSession(result) + goToStep('email-verification') + } + + if (step === 'sign-up' || step === 'initializing') { + return ( +
+

+ Custom Auth UI +

+ setEmailInput(e.target.value)} + className="w-full rounded-md border border-emerald-300 bg-white px-2 py-1 text-sm mb-2" + /> + +
+ ) + } + + if (step === 'email-verification') { + return ( +
+ Magic link sent to {email}. Check + your inbox. +
+ ) + } + + if (step === 'verifying-otp') { + return ( +
+ {verifyError ? ( + + Verify failed: {verifyError.message} + + ) : isVerifying ? ( + 'Verifying...' + ) : ( + 'Preparing verification...' + )} +
+ ) + } + + return null +} function CustomConfirmUI({ pendingRequest, @@ -355,11 +486,37 @@ function WalletPanel() { export function App() { const { connect, connectors } = useConnect() const { isConnected } = useAccount() + const [authMode, setAuthMode] = useState(readPersistedAuthMode) + const authModeSelectId = useId() return (
{!isConnected ? ( <> +
+ + +
+ - + + {authMode === 'default' && } + {authMode === 'children' && ( + {(auth) => } + )} ) : ( diff --git a/packages/react-kit/src/auth/index.tsx b/packages/react-kit/src/auth/index.tsx index 510db68a..896b8e52 100644 --- a/packages/react-kit/src/auth/index.tsx +++ b/packages/react-kit/src/auth/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { type ReactNode, useEffect } from 'react' import { StatusView } from '../shared/components/StatusView' import { useAuth } from './hooks/useAuth' import { EmailVerification } from './pages/EmailVerification' @@ -28,8 +28,15 @@ function WalletSelection() { ) } -export function AuthFlow() { - const { step, goToStep } = useAuth() +export type AuthFlowRenderArgs = ReturnType + +export interface AuthFlowProps { + children?: (args: AuthFlowRenderArgs) => ReactNode +} + +export function AuthFlow({ children }: AuthFlowProps = {}) { + const auth = useAuth() + const { step, goToStep } = auth useEffect(() => { if (step === 'initializing' && hasMagicLinkCodeInUrl()) { @@ -37,6 +44,10 @@ export function AuthFlow() { } }, [step, goToStep]) + if (typeof children === 'function') { + return children(auth) + } + switch (step) { case 'initializing': return null diff --git a/packages/react-kit/src/auth/pages/Verifying.tsx b/packages/react-kit/src/auth/pages/Verifying.tsx index 0084df79..24dcea14 100644 --- a/packages/react-kit/src/auth/pages/Verifying.tsx +++ b/packages/react-kit/src/auth/pages/Verifying.tsx @@ -1,5 +1,5 @@ import { useVerifyMagicLink } from '@zerodev/wallet-react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { AppLogo } from '../../shared/components/AppLogo' import { Button } from '../../shared/components/Button' import { ScreenWrapper } from '../../shared/components/ScreenWrapper' @@ -12,6 +12,18 @@ function getCodeFromUrl(): string | null { return new URLSearchParams(window.location.search).get('code') } +function stripCodeFromUrl(): void { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + if (!url.searchParams.has('code')) return + url.searchParams.delete('code') + window.history.replaceState({}, '', url.toString()) +} + +// Module-scope guard: survives React 18 strict-mode remounts, which would +// reset a per-component useRef. +let didVerifyForThisLoad = false + export function Verifying() { const { otpId, @@ -22,8 +34,6 @@ export function Verifying() { } = useAuth() const [code] = useState(getCodeFromUrl) - // ref to prevent useEffect firing twice in dev's StrictMode - const hasVerifiedRef = useRef(false) const { mutate: verifyMagicLink, error: verificationError, @@ -32,6 +42,7 @@ export function Verifying() { mutation: { onSuccess: async () => { clearOtpSession() + stripCodeFromUrl() goToStep('authenticated') config?.onSuccess?.() }, @@ -42,10 +53,10 @@ export function Verifying() { }) useEffect(() => { - if (hasVerifiedRef.current || !otpId || !otpEncryptionTargetBundle || !code) + if (didVerifyForThisLoad || !otpId || !otpEncryptionTargetBundle || !code) return - hasVerifiedRef.current = true + didVerifyForThisLoad = true verifyMagicLink({ otpId, code, otpEncryptionTargetBundle }) }, [otpId, otpEncryptionTargetBundle, code, verifyMagicLink]) diff --git a/packages/react-kit/src/index.ts b/packages/react-kit/src/index.ts index 07477b1c..1366167c 100644 --- a/packages/react-kit/src/index.ts +++ b/packages/react-kit/src/index.ts @@ -3,6 +3,7 @@ * React UI components and enhanced connector for ZeroDev Wallet SDK */ +export type { AuthFlowProps, AuthFlowRenderArgs } from './auth' export { AuthFlow } from './auth' // Auth