@zerodev/wallet-react-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/dist/_types/auth/authStoreSlice.d.ts +33 -0
- package/dist/_types/auth/authStoreSlice.d.ts.map +1 -0
- package/dist/_types/auth/components/BlobAnimation/index.d.ts +4 -0
- package/dist/_types/auth/components/BlobAnimation/index.d.ts.map +1 -0
- package/dist/_types/auth/components/CodeInput/index.d.ts +11 -0
- package/dist/_types/auth/components/CodeInput/index.d.ts.map +1 -0
- package/dist/_types/auth/hooks/useAuth.d.ts +18 -0
- package/dist/_types/auth/hooks/useAuth.d.ts.map +1 -0
- package/dist/_types/auth/index.d.ts +4 -0
- package/dist/_types/auth/index.d.ts.map +1 -0
- package/dist/_types/auth/pages/EmailVerification.d.ts +2 -0
- package/dist/_types/auth/pages/EmailVerification.d.ts.map +1 -0
- package/dist/_types/auth/pages/ErrorScreen.d.ts +9 -0
- package/dist/_types/auth/pages/ErrorScreen.d.ts.map +1 -0
- package/dist/_types/auth/pages/OtpInput.d.ts +2 -0
- package/dist/_types/auth/pages/OtpInput.d.ts.map +1 -0
- package/dist/_types/auth/pages/SignUp.d.ts +2 -0
- package/dist/_types/auth/pages/SignUp.d.ts.map +1 -0
- package/dist/_types/auth/pages/Verifying.d.ts +2 -0
- package/dist/_types/auth/pages/Verifying.d.ts.map +1 -0
- package/dist/_types/auth/pages/WalletSelection.d.ts +2 -0
- package/dist/_types/auth/pages/WalletSelection.d.ts.map +1 -0
- package/dist/_types/auth/types.d.ts +12 -0
- package/dist/_types/auth/types.d.ts.map +1 -0
- package/dist/_types/auth/utils/url.d.ts +3 -0
- package/dist/_types/auth/utils/url.d.ts.map +1 -0
- package/dist/_types/connector.d.ts +17 -0
- package/dist/_types/connector.d.ts.map +1 -0
- package/dist/_types/index.d.ts +10 -0
- package/dist/_types/index.d.ts.map +1 -0
- package/dist/_types/shared/components/PoweredBy/index.d.ts +6 -0
- package/dist/_types/shared/components/PoweredBy/index.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts +23 -0
- package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts +4 -0
- package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/index.d.ts +9 -0
- package/dist/_types/shared/components/Screen/index.d.ts.map +1 -0
- package/dist/_types/shared/components/SignUpFooter/index.d.ts +8 -0
- package/dist/_types/shared/components/SignUpFooter/index.d.ts.map +1 -0
- package/dist/_types/shared/components/StatusScreen/index.d.ts +10 -0
- package/dist/_types/shared/components/StatusScreen/index.d.ts.map +1 -0
- package/dist/_types/shared/components/TopNav/index.d.ts +10 -0
- package/dist/_types/shared/components/TopNav/index.d.ts.map +1 -0
- package/dist/_types/shared/hooks/useKitStore.d.ts +11 -0
- package/dist/_types/shared/hooks/useKitStore.d.ts.map +1 -0
- package/dist/_types/shared/utils/common.d.ts +5 -0
- package/dist/_types/shared/utils/common.d.ts.map +1 -0
- package/dist/_types/shared/utils/store.d.ts +4 -0
- package/dist/_types/shared/utils/store.d.ts.map +1 -0
- package/dist/_types/store.d.ts +25 -0
- package/dist/_types/store.d.ts.map +1 -0
- package/dist/_types/types.d.ts +38 -0
- package/dist/_types/types.d.ts.map +1 -0
- package/dist/blob.webm +0 -0
- package/dist/error.webp +0 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +1374 -0
- package/dist/index.mjs.map +1 -0
- package/dist/loading.webp +0 -0
- package/dist/send.webp +0 -0
- package/dist/styles.css +2 -0
- package/dist/success.webp +0 -0
- package/package.json +96 -0
- package/src/assets.d.ts +14 -0
- package/src/auth/authStoreSlice.ts +180 -0
- package/src/auth/components/BlobAnimation/index.tsx +24 -0
- package/src/auth/components/CodeInput/index.tsx +118 -0
- package/src/auth/hooks/useAuth.ts +32 -0
- package/src/auth/index.tsx +108 -0
- package/src/auth/pages/EmailVerification.tsx +76 -0
- package/src/auth/pages/ErrorScreen.tsx +53 -0
- package/src/auth/pages/OtpInput.tsx +124 -0
- package/src/auth/pages/SignUp.tsx +303 -0
- package/src/auth/pages/Verifying.tsx +108 -0
- package/src/auth/pages/WalletSelection.tsx +53 -0
- package/src/auth/types.ts +23 -0
- package/src/auth/utils/url.ts +12 -0
- package/src/connector.ts +182 -0
- package/src/index.ts +25 -0
- package/src/shared/components/PoweredBy/index.tsx +26 -0
- package/src/shared/components/Screen/EllipticalRadial.tsx +65 -0
- package/src/shared/components/Screen/MultiRadialBackground.tsx +323 -0
- package/src/shared/components/Screen/index.tsx +65 -0
- package/src/shared/components/SignUpFooter/index.tsx +66 -0
- package/src/shared/components/StatusScreen/index.tsx +49 -0
- package/src/shared/components/TopNav/index.tsx +45 -0
- package/src/shared/hooks/useKitStore.ts +20 -0
- package/src/shared/utils/common.ts +26 -0
- package/src/shared/utils/store.ts +9 -0
- package/src/store.ts +44 -0
- package/src/types.ts +48 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Button, Icon, Input, ListItem, Text } from '@zerodev/react-ui'
|
|
2
|
+
import {
|
|
3
|
+
useAuthenticateOAuth,
|
|
4
|
+
useLoginPasskey,
|
|
5
|
+
useRegisterPasskey,
|
|
6
|
+
useSendMagicLink,
|
|
7
|
+
useSendOTP,
|
|
8
|
+
} from '@zerodev/wallet-react'
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { SignUpFooter } from '../../shared/components/SignUpFooter'
|
|
11
|
+
import { isValidEmailAddress } from '../../shared/utils/common'
|
|
12
|
+
import { BlobAnimation } from '../components/BlobAnimation'
|
|
13
|
+
import { useAuth } from '../hooks/useAuth'
|
|
14
|
+
|
|
15
|
+
function isCancellationError(err: unknown): boolean {
|
|
16
|
+
if (!(err instanceof Error)) return false
|
|
17
|
+
// WebAuthn / passkey
|
|
18
|
+
if (err.name === 'AbortError' || err.name === 'NotAllowedError') return true
|
|
19
|
+
// OAuth (existing logic, message-based)
|
|
20
|
+
const msg = err.message.toLowerCase()
|
|
21
|
+
return msg.includes('oauth popup was closed')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SignUp() {
|
|
25
|
+
const { goToStep, setEmail, setOtpSession, config, enabledMethods } =
|
|
26
|
+
useAuth()
|
|
27
|
+
const [agreedToTerms, setAgreedToTerms] = useState(false)
|
|
28
|
+
const [highlightAgreement, setHighlightAgreement] = useState(false)
|
|
29
|
+
const [emailInput, setEmailInput] = useState('')
|
|
30
|
+
const shouldUseOtp = config?.emailAuthMethod === 'otp'
|
|
31
|
+
const { mutateAsync: sendOtp, isPending: isSendOtpPending } = useSendOTP()
|
|
32
|
+
const { mutateAsync: sendMagicLink, isPending: isSendMagicLinkPending } =
|
|
33
|
+
useSendMagicLink()
|
|
34
|
+
const isEmailLoading = isSendOtpPending || isSendMagicLinkPending
|
|
35
|
+
|
|
36
|
+
const [error, setError] = useState<string | null>(null)
|
|
37
|
+
|
|
38
|
+
const { mutateAsync: authenticateOAuth, isPending: isGoogleLoading } =
|
|
39
|
+
useAuthenticateOAuth({
|
|
40
|
+
mutation: {
|
|
41
|
+
onSuccess: async () => {
|
|
42
|
+
goToStep('authenticated')
|
|
43
|
+
config?.onSuccess?.()
|
|
44
|
+
},
|
|
45
|
+
onError: (err) => {
|
|
46
|
+
config?.onError?.(err)
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const { mutate: registerPasskey, isPending: isRegisterPasskeyPending } =
|
|
52
|
+
useRegisterPasskey({
|
|
53
|
+
mutation: {
|
|
54
|
+
onSuccess: () => {
|
|
55
|
+
goToStep('authenticated')
|
|
56
|
+
config?.onSuccess?.()
|
|
57
|
+
},
|
|
58
|
+
onError: (err) => {
|
|
59
|
+
if (!isCancellationError(err)) {
|
|
60
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
61
|
+
}
|
|
62
|
+
config?.onError?.(err)
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const { mutate: loginPasskey, isPending: isLoginPasskeyPending } =
|
|
68
|
+
useLoginPasskey({
|
|
69
|
+
mutation: {
|
|
70
|
+
onSuccess: () => {
|
|
71
|
+
goToStep('authenticated')
|
|
72
|
+
config?.onSuccess?.()
|
|
73
|
+
},
|
|
74
|
+
onError: (err) => {
|
|
75
|
+
if (!isCancellationError(err)) {
|
|
76
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
77
|
+
}
|
|
78
|
+
config?.onError?.(err)
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const anyPending =
|
|
84
|
+
isGoogleLoading ||
|
|
85
|
+
isEmailLoading ||
|
|
86
|
+
isRegisterPasskeyPending ||
|
|
87
|
+
isLoginPasskeyPending
|
|
88
|
+
const requiresAgreement = !!(
|
|
89
|
+
config?.termsAndConditionsUrl || config?.privacyPolicyUrl
|
|
90
|
+
)
|
|
91
|
+
const needsToAcceptAgreement = requiresAgreement && !agreedToTerms
|
|
92
|
+
|
|
93
|
+
const handleRegisterPasskey = () => {
|
|
94
|
+
if (anyPending) return
|
|
95
|
+
if (needsToAcceptAgreement) {
|
|
96
|
+
setHighlightAgreement(true)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
setError(null)
|
|
100
|
+
registerPasskey()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleLoginPasskey = () => {
|
|
104
|
+
if (anyPending) return
|
|
105
|
+
if (needsToAcceptAgreement) {
|
|
106
|
+
setHighlightAgreement(true)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
setError(null)
|
|
110
|
+
loginPasskey()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleGoogleAuth = async () => {
|
|
114
|
+
if (needsToAcceptAgreement) {
|
|
115
|
+
setHighlightAgreement(true)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
setError(null)
|
|
119
|
+
try {
|
|
120
|
+
await authenticateOAuth({ provider: 'google' })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
123
|
+
if (!isCancellationError(err)) {
|
|
124
|
+
setError(message)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const handleEmailOtp = async () => {
|
|
130
|
+
if (!emailInput || anyPending) return
|
|
131
|
+
if (!isValidEmailAddress(emailInput)) return
|
|
132
|
+
if (needsToAcceptAgreement) {
|
|
133
|
+
setHighlightAgreement(true)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setError(null)
|
|
138
|
+
try {
|
|
139
|
+
const { otpId, otpEncryptionTargetBundle } = await sendOtp({
|
|
140
|
+
email: emailInput,
|
|
141
|
+
})
|
|
142
|
+
setEmail(emailInput)
|
|
143
|
+
setOtpSession({ otpId, otpEncryptionTargetBundle })
|
|
144
|
+
goToStep('otp-input')
|
|
145
|
+
} catch (err) {
|
|
146
|
+
setError(
|
|
147
|
+
err instanceof Error ? err.message : 'Failed to send verification code',
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const handleEmailMagicLink = async () => {
|
|
153
|
+
if (!emailInput || anyPending) return
|
|
154
|
+
if (!isValidEmailAddress(emailInput)) return
|
|
155
|
+
if (needsToAcceptAgreement) {
|
|
156
|
+
setHighlightAgreement(true)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setError(null)
|
|
161
|
+
try {
|
|
162
|
+
const { otpId, otpEncryptionTargetBundle } = await sendMagicLink({
|
|
163
|
+
email: emailInput,
|
|
164
|
+
})
|
|
165
|
+
setEmail(emailInput)
|
|
166
|
+
setOtpSession({ otpId, otpEncryptionTargetBundle })
|
|
167
|
+
goToStep('email-verification')
|
|
168
|
+
} catch (err) {
|
|
169
|
+
setError(
|
|
170
|
+
err instanceof Error ? err.message : 'Failed to send verification code',
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const handleEmailSubmit = shouldUseOtp ? handleEmailOtp : handleEmailMagicLink
|
|
176
|
+
|
|
177
|
+
if (error) {
|
|
178
|
+
return (
|
|
179
|
+
<div className="zd:flex zd:items-center zd:justify-center zd:h-full">
|
|
180
|
+
<div className="zd:flex zd:flex-col zd:gap-4 zd:max-w-md">
|
|
181
|
+
<Text className="zd:text-h2 zd:text-center">Error occurred</Text>
|
|
182
|
+
<Text className="zd:text-center zd:text-red-500">{error}</Text>
|
|
183
|
+
<Button
|
|
184
|
+
action="primary"
|
|
185
|
+
text="Try again"
|
|
186
|
+
onClick={() => setError(null)}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:justify-between zd:pb-4 zd:overflow-y-auto zd:overflow-x-hidden">
|
|
195
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:justify-center">
|
|
196
|
+
<div className="zd:px-4 zd:flex zd:flex-col zd:items-center">
|
|
197
|
+
<div className="zd:w-full zd:px-16 zd:py-4">
|
|
198
|
+
<BlobAnimation className="zd:w-full zd:pointer-events-none zd:select-none" />
|
|
199
|
+
</div>
|
|
200
|
+
<Text className="zd:text-h2 zd:text-center">
|
|
201
|
+
Continue to your wallet
|
|
202
|
+
</Text>
|
|
203
|
+
<Text className="zd:mt-2 zd:text-center zd:text-greyScale/50">
|
|
204
|
+
Choose a sign-in method to proceed
|
|
205
|
+
</Text>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="zd:mt-6 zd:flex zd:flex-col zd:gap-4 zd:mb-4">
|
|
208
|
+
{enabledMethods.includes('passkey') && (
|
|
209
|
+
<>
|
|
210
|
+
<div className="zd:px-4 zd:flex zd:flex-col zd:gap-2">
|
|
211
|
+
<Button
|
|
212
|
+
action="secondary"
|
|
213
|
+
text="Create a passkey"
|
|
214
|
+
iconName="key"
|
|
215
|
+
trailIcon
|
|
216
|
+
disabled={anyPending}
|
|
217
|
+
onClick={handleRegisterPasskey}
|
|
218
|
+
/>
|
|
219
|
+
<Button
|
|
220
|
+
action="secondary"
|
|
221
|
+
text="Log in with passkey"
|
|
222
|
+
iconName="key"
|
|
223
|
+
trailIcon
|
|
224
|
+
disabled={anyPending}
|
|
225
|
+
onClick={handleLoginPasskey}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="zd:flex zd:items-center zd:gap-3">
|
|
229
|
+
<div className="zd:h-px zd:flex-1 zd:bg-greyScale/30" />
|
|
230
|
+
<Text className="zd:text-body3">or</Text>
|
|
231
|
+
<div className="zd:h-px zd:flex-1 zd:bg-greyScale/30" />
|
|
232
|
+
</div>
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
<div className="zd:px-4 zd:flex zd:flex-col zd:gap-2">
|
|
236
|
+
{enabledMethods.includes('google') && (
|
|
237
|
+
<ListItem
|
|
238
|
+
iconName="google"
|
|
239
|
+
title="Google"
|
|
240
|
+
chevron
|
|
241
|
+
className="zd:rounded-3xl"
|
|
242
|
+
disabled={anyPending}
|
|
243
|
+
onClick={handleGoogleAuth}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
246
|
+
{enabledMethods.includes('email') && (
|
|
247
|
+
<Input
|
|
248
|
+
iconName="email"
|
|
249
|
+
placeholder="Enter your email"
|
|
250
|
+
value={emailInput}
|
|
251
|
+
onChange={(e) => setEmailInput(e.target.value)}
|
|
252
|
+
type="email"
|
|
253
|
+
autoCapitalize="none"
|
|
254
|
+
autoComplete="email"
|
|
255
|
+
disabled={anyPending}
|
|
256
|
+
variant="listItemStyle"
|
|
257
|
+
containerClassName="zd:rounded-3xl"
|
|
258
|
+
onKeyDown={(e) => {
|
|
259
|
+
if (e.key === 'Enter' && emailInput && !anyPending) {
|
|
260
|
+
void handleEmailSubmit()
|
|
261
|
+
}
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{isEmailLoading ? (
|
|
265
|
+
<div className="zd:w-13 zd:h-13 zd:flex zd:items-center zd:justify-center">
|
|
266
|
+
<div className="zd:w-5 zd:h-5 zd:border-2 zd:border-solarOrange zd:border-t-transparent zd:rounded-full zd:animate-spin" />
|
|
267
|
+
</div>
|
|
268
|
+
) : (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
disabled={
|
|
272
|
+
!isValidEmailAddress(emailInput) || needsToAcceptAgreement
|
|
273
|
+
}
|
|
274
|
+
className={`zd:w-13 zd:h-13 zd:rounded-2xl zd:flex zd:items-center zd:justify-center zd:transition-colors ${
|
|
275
|
+
isValidEmailAddress(emailInput) && !needsToAcceptAgreement
|
|
276
|
+
? 'zd:cursor-pointer'
|
|
277
|
+
: 'zd:cursor-not-allowed zd:opacity-50'
|
|
278
|
+
}`}
|
|
279
|
+
onClick={() => handleEmailSubmit()}
|
|
280
|
+
>
|
|
281
|
+
<Icon name="chevronRight" className="zd:text-greyScale" />
|
|
282
|
+
</button>
|
|
283
|
+
)}
|
|
284
|
+
</Input>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="zd:px-4">
|
|
290
|
+
<SignUpFooter
|
|
291
|
+
termsAndConditionsUrl={config?.termsAndConditionsUrl}
|
|
292
|
+
privacyPolicyUrl={config?.privacyPolicyUrl}
|
|
293
|
+
agreedToTerms={agreedToTerms}
|
|
294
|
+
setAgreedToTerms={(agreed) => {
|
|
295
|
+
setAgreedToTerms(agreed)
|
|
296
|
+
if (agreed) setHighlightAgreement(false)
|
|
297
|
+
}}
|
|
298
|
+
highlight={highlightAgreement}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Button } from '@zerodev/react-ui'
|
|
2
|
+
import { useVerifyMagicLink } from '@zerodev/wallet-react'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { PoweredBy } from '../../shared/components/PoweredBy'
|
|
5
|
+
import { StatusScreen } from '../../shared/components/StatusScreen'
|
|
6
|
+
import { useAuth } from '../hooks/useAuth'
|
|
7
|
+
import { stripMagicLinkCodeFromUrl } from '../utils/url'
|
|
8
|
+
|
|
9
|
+
function getCodeFromUrl(): string | null {
|
|
10
|
+
if (typeof window === 'undefined') return null
|
|
11
|
+
return new URLSearchParams(window.location.search).get('code')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Verifying() {
|
|
15
|
+
const {
|
|
16
|
+
otpId,
|
|
17
|
+
otpEncryptionTargetBundle,
|
|
18
|
+
goToStep,
|
|
19
|
+
clearOtpSession,
|
|
20
|
+
config,
|
|
21
|
+
} = useAuth()
|
|
22
|
+
const [code] = useState<string | null>(getCodeFromUrl)
|
|
23
|
+
const [error, setError] = useState<Error | null>(null)
|
|
24
|
+
|
|
25
|
+
// ref to prevent useEffect firing twice in dev's StrictMode
|
|
26
|
+
const hasVerifiedRef = useRef(false)
|
|
27
|
+
const { mutate: verifyMagicLink, isPending: isVerificationLoading } =
|
|
28
|
+
useVerifyMagicLink({
|
|
29
|
+
mutation: {
|
|
30
|
+
onSuccess: async () => {
|
|
31
|
+
clearOtpSession()
|
|
32
|
+
goToStep('authenticated')
|
|
33
|
+
config?.onSuccess?.()
|
|
34
|
+
},
|
|
35
|
+
onError: (err) => {
|
|
36
|
+
setError(err)
|
|
37
|
+
config?.onError?.(err)
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (hasVerifiedRef.current || !code) return
|
|
44
|
+
hasVerifiedRef.current = true
|
|
45
|
+
|
|
46
|
+
// No active OTP session — most commonly the user tapped the same
|
|
47
|
+
// magic link again after a successful verify, which cleared the
|
|
48
|
+
// session. Skip the (doomed) mutation, clean the URL, and drop
|
|
49
|
+
// back to step=null so the host app's own routing handles the
|
|
50
|
+
// already-authenticated user without flashing an error.
|
|
51
|
+
if (!otpId || !otpEncryptionTargetBundle) {
|
|
52
|
+
stripMagicLinkCodeFromUrl()
|
|
53
|
+
goToStep(null)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
verifyMagicLink({ otpId, code, otpEncryptionTargetBundle })
|
|
58
|
+
}, [otpId, otpEncryptionTargetBundle, code, verifyMagicLink, goToStep])
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:items-center zd:justify-center">
|
|
63
|
+
{!error && isVerificationLoading && (
|
|
64
|
+
<StatusScreen imageName="loading" title="Verifying Your Email">
|
|
65
|
+
Please wait while we securely connect your wallet.
|
|
66
|
+
</StatusScreen>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{!error && !code && !isVerificationLoading && (
|
|
70
|
+
<>
|
|
71
|
+
<StatusScreen imageName="error" title="Invalid Link">
|
|
72
|
+
This verification link is invalid or incomplete.
|
|
73
|
+
<br />
|
|
74
|
+
Please check your email and try again with the correct link.
|
|
75
|
+
</StatusScreen>
|
|
76
|
+
<Button
|
|
77
|
+
action="primary"
|
|
78
|
+
onClick={() => {
|
|
79
|
+
stripMagicLinkCodeFromUrl()
|
|
80
|
+
goToStep('sign-up')
|
|
81
|
+
}}
|
|
82
|
+
text="Choose another sign-in method"
|
|
83
|
+
/>
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{error != null && (
|
|
88
|
+
<>
|
|
89
|
+
<StatusScreen imageName="error" title="Oops, something went wrong">
|
|
90
|
+
We couldn't complete the sign-in process. This could be due to
|
|
91
|
+
timeout, an expired link, or a cancelled request.
|
|
92
|
+
</StatusScreen>
|
|
93
|
+
<Button
|
|
94
|
+
action="primary"
|
|
95
|
+
onClick={() => {
|
|
96
|
+
stripMagicLinkCodeFromUrl()
|
|
97
|
+
goToStep('sign-up')
|
|
98
|
+
}}
|
|
99
|
+
text="Choose another sign-in method"
|
|
100
|
+
/>
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
|
|
106
|
+
</>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ListItem, Text } from '@zerodev/react-ui'
|
|
2
|
+
import { useConnect } from 'wagmi'
|
|
3
|
+
import { PoweredBy } from '../../shared/components/PoweredBy'
|
|
4
|
+
import { useAuth } from '../hooks/useAuth'
|
|
5
|
+
|
|
6
|
+
export function WalletSelection() {
|
|
7
|
+
const { goToStep } = useAuth()
|
|
8
|
+
const { connect, connectors, isPending } = useConnect()
|
|
9
|
+
|
|
10
|
+
const externalConnectors = connectors.filter((c) => c.id !== 'zerodev-wallet')
|
|
11
|
+
|
|
12
|
+
const handleSelect = (connector: (typeof connectors)[number]) => {
|
|
13
|
+
connect(
|
|
14
|
+
{ connector },
|
|
15
|
+
{
|
|
16
|
+
onSuccess: () => {
|
|
17
|
+
goToStep(null)
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:justify-center">
|
|
26
|
+
<Text className="zd:text-h2 zd:text-center">Select your wallet</Text>
|
|
27
|
+
|
|
28
|
+
<div className="zd:flex zd:flex-col zd:gap-2">
|
|
29
|
+
{externalConnectors.length === 0 ? (
|
|
30
|
+
<Text className="zd:text-center">
|
|
31
|
+
No wallets detected. Install a browser wallet extension to
|
|
32
|
+
continue.
|
|
33
|
+
</Text>
|
|
34
|
+
) : (
|
|
35
|
+
externalConnectors.map((connector) => (
|
|
36
|
+
<ListItem
|
|
37
|
+
key={connector.uid}
|
|
38
|
+
title={connector.name}
|
|
39
|
+
iconName="walletOutline"
|
|
40
|
+
{...(connector.icon ? { imageUri: connector.icon } : {})}
|
|
41
|
+
disabled={isPending}
|
|
42
|
+
onClick={() => handleSelect(connector)}
|
|
43
|
+
className="zd:rounded-3xl"
|
|
44
|
+
/>
|
|
45
|
+
))
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
|
|
51
|
+
</>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type AuthMethod = 'email' | 'google' | 'passkey'
|
|
2
|
+
|
|
3
|
+
export type AuthStep =
|
|
4
|
+
| 'sign-up'
|
|
5
|
+
| 'email-verification'
|
|
6
|
+
| 'otp-input'
|
|
7
|
+
| 'verifying-otp'
|
|
8
|
+
| 'passkey-prompt'
|
|
9
|
+
| 'oauth-in-progress'
|
|
10
|
+
| 'wallet-selection'
|
|
11
|
+
| 'authenticated'
|
|
12
|
+
| 'error'
|
|
13
|
+
|
|
14
|
+
export type EmailAuthMethod = 'magicLink' | 'otp'
|
|
15
|
+
|
|
16
|
+
export interface AuthConfig {
|
|
17
|
+
enabledMethods: AuthMethod[]
|
|
18
|
+
emailAuthMethod?: EmailAuthMethod
|
|
19
|
+
termsAndConditionsUrl?: string
|
|
20
|
+
privacyPolicyUrl?: string
|
|
21
|
+
onSuccess?: () => void
|
|
22
|
+
onError?: (error: unknown) => void
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function hasMagicLinkCodeInUrl(): boolean {
|
|
2
|
+
if (typeof window === 'undefined') return false
|
|
3
|
+
return new URLSearchParams(window.location.search).has('code')
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function stripMagicLinkCodeFromUrl(): void {
|
|
7
|
+
if (typeof window === 'undefined') return
|
|
8
|
+
const url = new URL(window.location.href)
|
|
9
|
+
if (!url.searchParams.has('code')) return
|
|
10
|
+
url.searchParams.delete('code')
|
|
11
|
+
window.history.replaceState(null, '', url.toString())
|
|
12
|
+
}
|
package/src/connector.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { CreateConnectorFn } from '@wagmi/core'
|
|
2
|
+
import type {
|
|
3
|
+
ZeroDevProvider,
|
|
4
|
+
ZeroDevWalletConnectorParams,
|
|
5
|
+
} from '@zerodev/wallet-react'
|
|
6
|
+
import {
|
|
7
|
+
zeroDevWallet as baseZeroDevWallet,
|
|
8
|
+
NotAuthenticatedError,
|
|
9
|
+
} from '@zerodev/wallet-react'
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import type { AuthConfig } from './auth/types'
|
|
12
|
+
import { createStore } from './store.js'
|
|
13
|
+
import type { Request, RequestMethod } from './types.js'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SIGNING_PROMPT_METHODS: RequestMethod[] = [
|
|
16
|
+
'eth_sendTransaction',
|
|
17
|
+
'wallet_sendTransaction',
|
|
18
|
+
'wallet_sendCalls',
|
|
19
|
+
'personal_sign',
|
|
20
|
+
'eth_signTypedData_v4',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export type ZeroDevKitConfig = {
|
|
24
|
+
auth?: AuthConfig
|
|
25
|
+
/**
|
|
26
|
+
* Optional brand logo rendered in the auth flow's top nav. When omitted,
|
|
27
|
+
* no logo is shown. `PoweredBy` always shows the ZeroDev mark independently.
|
|
28
|
+
*/
|
|
29
|
+
logo?: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ZeroDevKitConnectorParams = ZeroDevWalletConnectorParams & {
|
|
33
|
+
config?: ZeroDevKitConfig
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireUserConfirmation(
|
|
37
|
+
store: ReturnType<typeof createStore>,
|
|
38
|
+
request: Request,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
return new Promise<void>((resolve, reject) => {
|
|
41
|
+
store.getState().addPendingRequest({
|
|
42
|
+
id: crypto.randomUUID(),
|
|
43
|
+
...request,
|
|
44
|
+
resolve,
|
|
45
|
+
reject,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves when the user completes the auth flow (`step === 'authenticated'`),
|
|
52
|
+
* rejects when they dismiss it (`step === null`).
|
|
53
|
+
*/
|
|
54
|
+
function waitForAuthFlow(store: ReturnType<typeof createStore>): Promise<void> {
|
|
55
|
+
return new Promise<void>((resolve, reject) => {
|
|
56
|
+
const unsub = store.subscribe(
|
|
57
|
+
(state) => state.auth.step,
|
|
58
|
+
(step) => {
|
|
59
|
+
if (step === 'authenticated') {
|
|
60
|
+
unsub()
|
|
61
|
+
resolve()
|
|
62
|
+
} else if (step === null) {
|
|
63
|
+
// User dismissed the auth flow (e.g. clicked the TopNav close
|
|
64
|
+
// button) — abort the pending wagmi connect.
|
|
65
|
+
unsub()
|
|
66
|
+
reject(new Error('Auth flow dismissed'))
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function zeroDevWallet(
|
|
74
|
+
params: ZeroDevKitConnectorParams,
|
|
75
|
+
): CreateConnectorFn {
|
|
76
|
+
const baseFactory = baseZeroDevWallet(params)
|
|
77
|
+
const store = createStore({ logo: params.config?.logo })
|
|
78
|
+
|
|
79
|
+
// Initialize auth config if provided
|
|
80
|
+
if (params.config?.auth) {
|
|
81
|
+
store.getState().auth.initialize(params.config.auth)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (wagmiConfig) => {
|
|
85
|
+
const connector = baseFactory(wagmiConfig)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
...connector,
|
|
89
|
+
|
|
90
|
+
async connect(connectParams) {
|
|
91
|
+
// Try to connect first. If a session is persisted, base will load it
|
|
92
|
+
// and resolve — no auth UI needed. If not, it throws
|
|
93
|
+
// `NotAuthenticatedError` and we surface SignUp.
|
|
94
|
+
//
|
|
95
|
+
// This avoids forcing every connect to await the base store's init
|
|
96
|
+
// (which `isAuthorized()` deliberately skips for perf — see
|
|
97
|
+
// packages/react/src/core/connector.ts), and avoids flashing the auth
|
|
98
|
+
// UI on first paint when the session hasn't loaded yet.
|
|
99
|
+
try {
|
|
100
|
+
return await connector.connect(connectParams)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (
|
|
103
|
+
connectParams?.isReconnecting ||
|
|
104
|
+
!params.config?.auth ||
|
|
105
|
+
!(error instanceof NotAuthenticatedError)
|
|
106
|
+
) {
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (store.getState().auth.step !== null) {
|
|
111
|
+
throw new Error('Auth flow already in progress')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
store.getState().auth.goToStep('sign-up')
|
|
115
|
+
await waitForAuthFlow(store)
|
|
116
|
+
return connector.connect(connectParams)
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async disconnect() {
|
|
121
|
+
await connector.disconnect?.()
|
|
122
|
+
if (params.config?.auth) {
|
|
123
|
+
store.getState().auth.reset()
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async setup() {
|
|
128
|
+
await connector.setup?.()
|
|
129
|
+
|
|
130
|
+
// Request-wrapping is only meaningful in the browser (it gates calls
|
|
131
|
+
// on UI confirmation). Skip during SSR — getProvider() touches
|
|
132
|
+
// `window` for EIP-6963 discovery and would crash on the server.
|
|
133
|
+
if (typeof window === 'undefined') return
|
|
134
|
+
|
|
135
|
+
// Signing is pinned to background mode: the prompt-mode confirmation UI
|
|
136
|
+
// is not part of this package's public surface, so requests always pass
|
|
137
|
+
// through without gating. The wrapping logic below is retained but
|
|
138
|
+
// unreachable.
|
|
139
|
+
// const signing = params.config?.signing
|
|
140
|
+
const signing = { mode: 'background' } as const
|
|
141
|
+
if (signing.mode === 'background') return
|
|
142
|
+
|
|
143
|
+
const methods =
|
|
144
|
+
// (signing?.mode === 'prompt' && signing.methods) ||
|
|
145
|
+
DEFAULT_SIGNING_PROMPT_METHODS
|
|
146
|
+
|
|
147
|
+
const provider = (await connector.getProvider()) as ZeroDevProvider
|
|
148
|
+
const baseRequest = provider.request.bind(provider)
|
|
149
|
+
|
|
150
|
+
// todo: move Request type to core package and use as the method and params type here
|
|
151
|
+
// then we can remove as RequestMethod and spread in return baseRequest
|
|
152
|
+
provider.request = async ({
|
|
153
|
+
method,
|
|
154
|
+
params: reqParams,
|
|
155
|
+
}: {
|
|
156
|
+
method: string
|
|
157
|
+
params?: unknown[]
|
|
158
|
+
}) => {
|
|
159
|
+
const { userConfirmationListenerActive } = store.getState()
|
|
160
|
+
if (
|
|
161
|
+
userConfirmationListenerActive &&
|
|
162
|
+
methods.includes(method as RequestMethod)
|
|
163
|
+
) {
|
|
164
|
+
await requireUserConfirmation(store, {
|
|
165
|
+
method: method as RequestMethod,
|
|
166
|
+
params: reqParams,
|
|
167
|
+
} as Request)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return baseRequest({
|
|
171
|
+
method,
|
|
172
|
+
...(reqParams && { params: reqParams }),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
getKitStore() {
|
|
178
|
+
return store
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|