Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 163 additions & 2 deletions apps/react-example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<div className="rounded-xl border border-emerald-300 bg-emerald-50 p-4">
<h3 className="text-sm font-semibold text-emerald-900 mb-2">
Custom Auth UI
</h3>
<input
type="email"
placeholder="you@example.com"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
className="w-full rounded-md border border-emerald-300 bg-white px-2 py-1 text-sm mb-2"
/>
<button
type="button"
disabled={!emailInput.trim() || isSending}
onClick={handleSend}
className="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
>
{isSending ? 'Sending magic link...' : 'Send magic link'}
</button>
</div>
)
}

if (step === 'email-verification') {
return (
<div className="rounded-xl border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-900">
Magic link sent to <span className="font-semibold">{email}</span>. Check
your inbox.
</div>
)
}

if (step === 'verifying-otp') {
return (
<div className="rounded-xl border border-emerald-300 bg-emerald-50 p-4 text-sm text-emerald-900">
{verifyError ? (
<span className="text-red-700">
Verify failed: {verifyError.message}
</span>
) : isVerifying ? (
'Verifying...'
) : (
'Preparing verification...'
)}
</div>
)
}

return null
}

function CustomConfirmUI({
pendingRequest,
Expand Down Expand Up @@ -355,19 +486,49 @@ function WalletPanel() {
export function App() {
const { connect, connectors } = useConnect()
const { isConnected } = useAccount()
const [authMode, setAuthMode] = useState<AuthFlowMode>(readPersistedAuthMode)
const authModeSelectId = useId()

return (
<div className="mx-auto h-[800px] w-[500px]">
{!isConnected ? (
<>
<div className="mb-4 rounded-lg bg-gray-50 p-3">
<label
htmlFor={authModeSelectId}
className="block text-xs font-medium text-gray-700 mb-1"
>
AuthFlow mode
</label>
<select
id={authModeSelectId}
value={authMode}
onChange={(e) => {
const next = e.target.value as AuthFlowMode
setAuthMode(next)
window.localStorage.setItem(AUTH_MODE_STORAGE_KEY, next)
}}
className="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-900"
>
<option value="default">Mode 1: default UI</option>
<option value="children">
Mode 2: custom UI via children function
</option>
</select>
</div>

<button
type="button"
onClick={() => connect({ connector: connectors[0] })}
className="mb-4 w-full rounded-lg bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 cursor-pointer"
>
Connect
</button>
<AuthFlow />

{authMode === 'default' && <AuthFlow />}
{authMode === 'children' && (
<AuthFlow>{(auth) => <CustomAuthUI auth={auth} />}</AuthFlow>
)}
</>
) : (
<WalletPanel />
Expand Down
17 changes: 14 additions & 3 deletions packages/react-kit/src/auth/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,15 +28,26 @@ function WalletSelection() {
)
}

export function AuthFlow() {
const { step, goToStep } = useAuth()
export type AuthFlowRenderArgs = ReturnType<typeof useAuth>

export interface AuthFlowProps {
children?: (args: AuthFlowRenderArgs) => ReactNode
}

export function AuthFlow({ children }: AuthFlowProps = {}) {
const auth = useAuth()
const { step, goToStep } = auth

useEffect(() => {
if (step === 'initializing' && hasMagicLinkCodeInUrl()) {
goToStep('verifying-otp')
}
}, [step, goToStep])

if (typeof children === 'function') {
return children(auth)
}

switch (step) {
case 'initializing':
return null
Expand Down
21 changes: 16 additions & 5 deletions packages/react-kit/src/auth/pages/Verifying.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand All @@ -22,8 +34,6 @@ export function Verifying() {
} = useAuth()
const [code] = useState<string | null>(getCodeFromUrl)

// ref to prevent useEffect firing twice in dev's StrictMode
const hasVerifiedRef = useRef(false)
const {
mutate: verifyMagicLink,
error: verificationError,
Expand All @@ -32,6 +42,7 @@ export function Verifying() {
mutation: {
onSuccess: async () => {
clearOtpSession()
stripCodeFromUrl()
goToStep('authenticated')
config?.onSuccess?.()
},
Expand All @@ -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])

Expand Down
1 change: 1 addition & 0 deletions packages/react-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down