@zerodev/wallet-react-ui 0.0.12 → 0.0.13

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.
Files changed (42) hide show
  1. package/dist/_types/auth/authStoreSlice.d.ts +31 -0
  2. package/dist/_types/auth/authStoreSlice.d.ts.map +1 -1
  3. package/dist/_types/auth/components/WalletSheet/index.d.ts.map +1 -1
  4. package/dist/_types/auth/hooks/useAuth.d.ts +5 -0
  5. package/dist/_types/auth/hooks/useAuth.d.ts.map +1 -1
  6. package/dist/_types/auth/index.d.ts.map +1 -1
  7. package/dist/_types/auth/pages/SignUp/InstalledWallets.d.ts.map +1 -1
  8. package/dist/_types/auth/pages/SignUp/MoreWallets.d.ts.map +1 -1
  9. package/dist/_types/auth/pages/SignUp/Wallet.d.ts.map +1 -1
  10. package/dist/_types/auth/pages/Verifying.d.ts.map +1 -1
  11. package/dist/_types/auth/pages/WalletConnecting.d.ts +11 -0
  12. package/dist/_types/auth/pages/WalletConnecting.d.ts.map +1 -0
  13. package/dist/_types/auth/types.d.ts +1 -1
  14. package/dist/_types/auth/types.d.ts.map +1 -1
  15. package/dist/_types/auth/utils/isCancellationError.d.ts +4 -0
  16. package/dist/_types/auth/utils/isCancellationError.d.ts.map +1 -1
  17. package/dist/_types/auth/utils/isResourceUnavailableError.d.ts +12 -0
  18. package/dist/_types/auth/utils/isResourceUnavailableError.d.ts.map +1 -0
  19. package/dist/_types/connector.d.ts.map +1 -1
  20. package/dist/index.cjs +2 -2
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.mjs +872 -723
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/styles.css +1 -1
  25. package/package.json +4 -4
  26. package/src/auth/authStoreSlice.ts +72 -3
  27. package/src/auth/components/WalletSheet/index.tsx +9 -18
  28. package/src/auth/hooks/useAuth.ts +7 -0
  29. package/src/auth/index.tsx +3 -0
  30. package/src/auth/pages/SignUp/InstalledWallets.tsx +18 -28
  31. package/src/auth/pages/SignUp/MoreWallets.tsx +14 -26
  32. package/src/auth/pages/SignUp/Wallet.tsx +11 -27
  33. package/src/auth/pages/Verifying.tsx +28 -8
  34. package/src/auth/pages/WalletConnecting.tsx +237 -0
  35. package/src/auth/types.ts +1 -0
  36. package/src/auth/utils/isCancellationError.ts +20 -1
  37. package/src/auth/utils/isResourceUnavailableError.ts +29 -0
  38. package/src/connector.ts +13 -4
  39. package/src/shared/components/StatusScreen/index.tsx +1 -1
  40. package/dist/_types/shared/components/TokenSummary/index.d.ts +0 -18
  41. package/dist/_types/shared/components/TokenSummary/index.d.ts.map +0 -1
  42. package/src/shared/components/TokenSummary/index.tsx +0 -50
@@ -15,6 +15,7 @@ export function Verifying() {
15
15
  useAuth()
16
16
  const [code] = useState<string | null>(getCodeFromUrl)
17
17
  const [error, setError] = useState<Error | null>(null)
18
+ const [sessionMissing, setSessionMissing] = useState(false)
18
19
 
19
20
  // ref to prevent useEffect firing twice in dev's StrictMode
20
21
  const hasVerifiedRef = useRef(false)
@@ -35,14 +36,15 @@ export function Verifying() {
35
36
  if (hasVerifiedRef.current || !code) return
36
37
  hasVerifiedRef.current = true
37
38
 
38
- // No active OTP session — most commonly the user tapped the same
39
- // magic link again after a successful verify, which cleared the
40
- // session. Skip the (doomed) mutation, clean the URL, and drop
41
- // back to step=null so the host app's own routing handles the
42
- // already-authenticated user without flashing an error.
39
+ // No active OTP session — the link was already used, expired, or opened
40
+ // in a browser that never started the email flow. Skip the (doomed)
41
+ // mutation and show the expired-link error below instead of silently
42
+ // dropping the step: a blank screen gives the user nothing to act on.
43
+ // The already-authenticated re-tap case is covered too — hosts route
44
+ // connected users away on their own (wagmi reconnect), so the error only
45
+ // stays up for users who genuinely can't be verified.
43
46
  if (!otpId || !otpEncryptionTargetBundle) {
44
- stripMagicLinkCodeFromUrl()
45
- goToStep(null)
47
+ setSessionMissing(true)
46
48
  return
47
49
  }
48
50
 
@@ -58,7 +60,25 @@ export function Verifying() {
58
60
  </StatusScreen>
59
61
  )}
60
62
 
61
- {!error && !code && !isVerificationLoading && (
63
+ {!error && sessionMissing && (
64
+ <>
65
+ <StatusScreen imageName="error" title="Link Expired">
66
+ This verification link has expired or was already used.
67
+ <br />
68
+ Please sign in again to request a new one.
69
+ </StatusScreen>
70
+ <Button
71
+ action="primary"
72
+ onClick={() => {
73
+ stripMagicLinkCodeFromUrl()
74
+ goToStep('sign-up')
75
+ }}
76
+ text="Choose another sign-in method"
77
+ />
78
+ </>
79
+ )}
80
+
81
+ {!error && !sessionMissing && !code && !isVerificationLoading && (
62
82
  <>
63
83
  <StatusScreen imageName="error" title="Invalid Link">
64
84
  This verification link is invalid or incomplete.
@@ -0,0 +1,237 @@
1
+ import { type Config, connect } from '@wagmi/core'
2
+ import { Button, PoweredBy } from '@zerodev/react-ui'
3
+ import { useEffect, useRef } from 'react'
4
+ import { useConfig, useConnectors } from 'wagmi'
5
+ import { StatusScreen } from '../../shared/components/StatusScreen'
6
+ import { useKitStore } from '../../shared/hooks/useKitStore'
7
+ import type { ConnectFailure } from '../authStoreSlice'
8
+ import { useAuth } from '../hooks/useAuth'
9
+ import { isCancellationError } from '../utils/isCancellationError'
10
+ import { isResourceUnavailableError } from '../utils/isResourceUnavailableError'
11
+
12
+ /**
13
+ * Connector uids with a connect() still open, per wagmi config. Outside React
14
+ * because the request outlives this component, and keyed by connector because
15
+ * several wallets can be left unanswered at once.
16
+ */
17
+ const openRequests = new WeakMap<Config, Set<string>>()
18
+
19
+ function openFor(config: Config): Set<string> {
20
+ let uids = openRequests.get(config)
21
+ if (!uids) {
22
+ uids = new Set()
23
+ openRequests.set(config, uids)
24
+ }
25
+ return uids
26
+ }
27
+
28
+ function describeConnectError(
29
+ err: unknown,
30
+ walletName: string,
31
+ ): ConnectFailure {
32
+ if (isCancellationError(err)) {
33
+ return {
34
+ title: 'Request declined',
35
+ message: `You declined the connection request in ${walletName}.`,
36
+ pending: false,
37
+ }
38
+ }
39
+ if (isResourceUnavailableError(err)) {
40
+ // Usually nothing failed: the wallet still has the first request open
41
+ // and will not prompt again until it is answered there. But -32002 is
42
+ // only "resource unavailable", so suggest the outstanding request rather
43
+ // than assert it. Not the wallet's own message: MetaMask's names RPC
44
+ // methods and the origin ("Request of type 'wallet_requestPermissions'
45
+ // already pending for origin…") — Reown shows its own copy here too.
46
+ return {
47
+ title: `Check ${walletName}`,
48
+ message:
49
+ `Open ${walletName} and check for an outstanding request. ` +
50
+ 'If one is waiting, approve or dismiss it, then try again.',
51
+ pending: true,
52
+ }
53
+ }
54
+ // Wallets throw raw JSON-RPC objects, viem throws Errors with
55
+ // `shortMessage`. Never render "[object Object]": fall back to a fixed
56
+ // sentence plus the code, the only actionable detail such an object has.
57
+ let message: string | undefined
58
+ let code: number | undefined
59
+ if (typeof err === 'string' && err.length > 0) {
60
+ message = err
61
+ } else if (typeof err === 'object' && err !== null) {
62
+ const e = err as {
63
+ shortMessage?: unknown
64
+ message?: unknown
65
+ code?: unknown
66
+ }
67
+ const text = e.shortMessage ?? e.message
68
+ if (typeof text === 'string' && text.length > 0) message = text
69
+ if (typeof e.code === 'number') code = e.code
70
+ }
71
+ return {
72
+ title: 'Couldn’t connect',
73
+ message:
74
+ message ??
75
+ `Something went wrong while connecting to ${walletName}. Please try again.` +
76
+ (code !== undefined ? ` (code ${code})` : ''),
77
+ pending: false,
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The `wallet-connecting` step: shown from picking an external wallet until it
83
+ * answers, which a locked or dismissed wallet never does — so leaving must
84
+ * always be possible.
85
+ *
86
+ * This page owns the connect() call, since React Query drops per-call
87
+ * callbacks when the sign-up page unmounts on the step change. The wallet
88
+ * button only records intent (`startWalletConnection`).
89
+ */
90
+ export function WalletConnecting() {
91
+ const {
92
+ pendingWallet,
93
+ connectError,
94
+ setConnectError,
95
+ clearPendingWallet,
96
+ goToStep,
97
+ } = useAuth()
98
+ const connectors = useConnectors()
99
+ const config = useConfig()
100
+ // Read outside React: the wallet can answer long after this unmounts.
101
+ const store = useKitStore()
102
+
103
+ const connector = pendingWallet
104
+ ? connectors.find((c) => c.uid === pendingWallet.connectorUid)
105
+ : undefined
106
+ const walletName = pendingWallet?.name ?? 'your wallet'
107
+
108
+ // Pop, don't push: pushing would leave `wallet-connecting` in the history,
109
+ // and sign-up's back arrow would remount this page and connect() again.
110
+ const leave = () => {
111
+ const auth = store.getState().auth
112
+ if (auth.stepHistory.length > 0) auth.goBack()
113
+ else auth.goToStep('sign-up')
114
+ }
115
+
116
+ const close = () => {
117
+ clearPendingWallet()
118
+ goToStep(null)
119
+ }
120
+
121
+ const attempt = () => {
122
+ // Starting an attempt means waiting again: drop the previous outcome so
123
+ // Try again shows the wallet rather than the failure it replaces.
124
+ setConnectError(null)
125
+
126
+ // Single-flight per wallet: a wallet rejects a second request rather than
127
+ // re-prompting, so re-adopt the open one — its promise still drives this
128
+ // screen.
129
+ if (connector && openFor(config).has(connector.uid)) return
130
+
131
+ // Connector gone from the config: nothing to connect to.
132
+ if (!connector) {
133
+ clearPendingWallet()
134
+ leave()
135
+ return
136
+ }
137
+ // Already the current connection: connect() would throw rather than
138
+ // prompt (its exact guard), so there is nothing to wait for. A connector
139
+ // that is connected but not current falls through on purpose — connect()
140
+ // then resolves silently and promotes it to current.
141
+ if (config.state.current === connector.uid) {
142
+ close()
143
+ return
144
+ }
145
+
146
+ // @wagmi/core's connect(), not useConnect's mutate: a promise keeps its
147
+ // handlers across remounts (Strict Mode) and after unmount, so a wallet
148
+ // answered once the user left still closes the widget.
149
+ const open = openFor(config)
150
+ open.add(connector.uid)
151
+ connect(config, { connector }).then(
152
+ () => {
153
+ open.delete(connector.uid)
154
+ close()
155
+ },
156
+ (err: unknown) => {
157
+ open.delete(connector.uid)
158
+ // The abandoned prompt is often dismissed much later, by which point
159
+ // the user has moved on. Act only while the flow is still waiting on
160
+ // THIS wallet. (Approval is not gated: the wallet is then connected,
161
+ // so ending the flow is right wherever they are.)
162
+ const auth = store.getState().auth
163
+ if (
164
+ auth.step !== 'wallet-connecting' ||
165
+ auth.pendingWallet?.connectorUid !== connector.uid
166
+ ) {
167
+ return
168
+ }
169
+ setConnectError(describeConnectError(err, walletName))
170
+ },
171
+ )
172
+ }
173
+
174
+ // wagmi can connect without our connect() promise resolving: after a reload
175
+ // the wallet's queued request belongs to a dead page, and approving it
176
+ // authorises the site through the connector's own events. Watch the store so
177
+ // the flow still closes.
178
+ useEffect(() => {
179
+ if (!connector) return
180
+ const uid = connector.uid
181
+ return config.subscribe(
182
+ (state) => (state.status === 'connected' ? state.current : null),
183
+ (currentUid) => {
184
+ if (!currentUid) return
185
+ const connected = config.state.connections.get(currentUid)?.connector
186
+ if (connected?.uid !== uid) return
187
+ const auth = store.getState().auth
188
+ auth.clearPendingWallet()
189
+ auth.goToStep(null)
190
+ },
191
+ )
192
+ }, [connector, config, store])
193
+
194
+ // Once on mount; the ref guards Strict Mode's double effect run.
195
+ const started = useRef(false)
196
+ // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only kick; `attempt` is intentionally the mount-time closure
197
+ useEffect(() => {
198
+ if (started.current) return
199
+ started.current = true
200
+ attempt()
201
+ }, [])
202
+
203
+ return (
204
+ <>
205
+ <div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:items-center zd:justify-center">
206
+ {connectError === null ? (
207
+ <StatusScreen imageName="loading" title={`Waiting for ${walletName}`}>
208
+ Approve or reject the request in your wallet.
209
+ <br />
210
+ Nothing showing? Open the wallet, the request may be waiting behind
211
+ its unlock screen.
212
+ </StatusScreen>
213
+ ) : (
214
+ <StatusScreen
215
+ imageName={connectError.pending ? 'loading' : 'error'}
216
+ title={connectError.title}
217
+ >
218
+ {connectError.message}
219
+ </StatusScreen>
220
+ )}
221
+ {/* EIP-1193 has no cancel, so the label promises only what it does. */}
222
+ <div className="zd:flex zd:flex-col zd:gap-1">
223
+ {connectError !== null && (
224
+ <Button action="primary" text="Try again" onClick={attempt} />
225
+ )}
226
+ <Button
227
+ action="secondary"
228
+ text="Choose another sign-in method"
229
+ onClick={leave}
230
+ />
231
+ </div>
232
+ </div>
233
+
234
+ <PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
235
+ </>
236
+ )
237
+ }
package/src/auth/types.ts CHANGED
@@ -6,6 +6,7 @@ export type AuthStep =
6
6
  | 'otp-input'
7
7
  | 'verifying-otp'
8
8
  | 'passkey-prompt'
9
+ | 'wallet-connecting'
9
10
  | 'oauth-in-progress'
10
11
  | 'authenticated'
11
12
  | 'error'
@@ -1,10 +1,29 @@
1
+ /**
2
+ * The user cancelled, rather than something failed: passkey prompt dismissed,
3
+ * OAuth popup closed, or a wallet connection request rejected.
4
+ */
1
5
  export function isCancellationError(err: unknown): boolean {
6
+ // EIP-1193 `4001` "user rejected", on any object: viem's
7
+ // UserRejectedRequestError, a wallet's own ProviderRpcError (an Error with
8
+ // `code`), or wagmi's rethrown raw JSON-RPC object. Walk `.cause`.
9
+ let current: unknown = err
10
+ for (let depth = 0; depth < 5 && isObject(current); depth++) {
11
+ if (current.code === 4001) return true
12
+ current = current.cause
13
+ }
14
+
2
15
  if (!(err instanceof Error)) return false
3
16
  // WebAuthn / passkey
4
17
  if (err.name === 'AbortError' || err.name === 'NotAllowedError') return true
5
- // EIP-1193 user rejection (external-wallet connect), via viem
18
+ // viem's rejection error when `code` was dropped
6
19
  if (err.name === 'UserRejectedRequestError') return true
7
20
  // OAuth (existing logic, message-based)
8
21
  const msg = err.message.toLowerCase()
9
22
  return msg.includes('oauth popup was closed')
10
23
  }
24
+
25
+ function isObject(
26
+ value: unknown,
27
+ ): value is { code?: unknown; cause?: unknown } {
28
+ return typeof value === 'object' && value !== null
29
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * EIP-1474 `-32002` "Resource unavailable". Injected wallets send it when a
3
+ * request for this origin is already open (popup closed without answering,
4
+ * then clicked again) and they will not prompt again until it is answered —
5
+ * wagmi's injected connector reads the code the same way ("prompt is already
6
+ * open").
7
+ *
8
+ * viem wraps it as `ResourceUnavailableRpcError`; wagmi's injected connector
9
+ * rethrows the raw JSON-RPC object. Accept both and walk `.cause`.
10
+ */
11
+ export function isResourceUnavailableError(err: unknown): boolean {
12
+ let current: unknown = err
13
+ for (let depth = 0; depth < 5 && isObject(current); depth++) {
14
+ if (
15
+ current.code === -32002 ||
16
+ current.name === 'ResourceUnavailableRpcError'
17
+ ) {
18
+ return true
19
+ }
20
+ current = current.cause
21
+ }
22
+ return false
23
+ }
24
+
25
+ function isObject(
26
+ value: unknown,
27
+ ): value is { code?: unknown; name?: unknown; cause?: unknown } {
28
+ return typeof value === 'object' && value !== null
29
+ }
package/src/connector.ts CHANGED
@@ -106,16 +106,25 @@ export function zeroDevWallet(
106
106
  },
107
107
 
108
108
  async setup() {
109
+ // Restore a persisted OTP session BEFORE base setup so an email flow
110
+ // survives a reload. The restore is a synchronous localStorage read;
111
+ // base setup does async work (wallet creation, session validation)
112
+ // that can be slow or fail. Magic-link verification races on this —
113
+ // `Verifying` reads the session once on mount, and gating the restore
114
+ // behind base setup made it see an empty session and strip the code
115
+ // from the URL. Restoring first runs synchronously inside wagmi's
116
+ // createConfig, before any React effect can observe the store.
117
+ if (typeof window !== 'undefined') {
118
+ store.getState().auth.initialize()
119
+ }
120
+
109
121
  await connector.setup?.()
110
122
 
111
123
  // Everything below is browser-only. Skip during SSR — getProvider()
112
124
  // touches `window` for EIP-6963 discovery and would crash on the
113
- // server, and the session restore reads localStorage.
125
+ // server.
114
126
  if (typeof window === 'undefined') return
115
127
 
116
- // Restore a persisted OTP session so an email flow survives a reload.
117
- store.getState().auth.initialize()
118
-
119
128
  // Signing is pinned to background mode: the prompt-mode confirmation UI
120
129
  // is not part of this package's public surface, so requests always pass
121
130
  // through without gating. The wrapping logic below is retained but
@@ -34,7 +34,7 @@ export function StatusScreen({
34
34
  <img
35
35
  src={STATE_IMAGES[imageName]}
36
36
  alt={imageName}
37
- className="zd:w-[118px] zd:h-[118px] zd:bg-transparent"
37
+ className="zd:w-[118px] zd:h-[118px] zd:object-contain zd:bg-transparent"
38
38
  />
39
39
  <div className="zd:flex zd:flex-col zd:gap-4 zd:items-center">
40
40
  <Text className="zd:text-h2 zd:text-center zd:whitespace-pre-wrap">
@@ -1,18 +0,0 @@
1
- export interface TokenSummaryProps {
2
- /** Token logo URL, shown in the tile floating over the card's top edge.
3
- * The tile renders empty (neutral background) when omitted. */
4
- tokenLogoUrl?: string;
5
- /** Pre-formatted fiat value, e.g. `"+$170.27"`. */
6
- fiatValue: string;
7
- /** Pre-formatted token amount, e.g. `"0.0652 ETH"`. */
8
- cryptoAmount: string;
9
- className?: string;
10
- }
11
- /**
12
- * Portfolio-value hero (Figma "Portfolio Value", `15873:58532`): a card
13
- * headed by a token logo tile that overhangs the top edge, with the fiat
14
- * value large and the crypto amount beneath. The overhang headroom is part
15
- * of the component, so consumers can stack it without clipping the tile.
16
- */
17
- export declare function TokenSummary({ tokenLogoUrl, fiatValue, cryptoAmount, className, }: TokenSummaryProps): import("react").JSX.Element;
18
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/shared/components/TokenSummary/index.tsx"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAA;IACjB,uDAAuD;IACvD,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAC3B,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,SAAS,GACV,EAAE,iBAAiB,+BAyBnB"}
@@ -1,50 +0,0 @@
1
- import { cn, Text, Wrapper } from '@zerodev/react-ui'
2
-
3
- export interface TokenSummaryProps {
4
- /** Token logo URL, shown in the tile floating over the card's top edge.
5
- * The tile renders empty (neutral background) when omitted. */
6
- tokenLogoUrl?: string
7
- /** Pre-formatted fiat value, e.g. `"+$170.27"`. */
8
- fiatValue: string
9
- /** Pre-formatted token amount, e.g. `"0.0652 ETH"`. */
10
- cryptoAmount: string
11
- className?: string
12
- }
13
-
14
- /**
15
- * Portfolio-value hero (Figma "Portfolio Value", `15873:58532`): a card
16
- * headed by a token logo tile that overhangs the top edge, with the fiat
17
- * value large and the crypto amount beneath. The overhang headroom is part
18
- * of the component, so consumers can stack it without clipping the tile.
19
- */
20
- export function TokenSummary({
21
- tokenLogoUrl,
22
- fiatValue,
23
- cryptoAmount,
24
- className,
25
- }: TokenSummaryProps) {
26
- return (
27
- <div className={cn('zd:relative zd:w-full zd:pt-7', className)}>
28
- <Wrapper
29
- variant="ghost"
30
- className="zd:flex zd:w-full zd:flex-col zd:items-center zd:gap-2 zd:rounded-2xl zd:px-2 zd:pt-16 zd:pb-2"
31
- >
32
- <Text className="zd:whitespace-nowrap zd:text-h1">{fiatValue}</Text>
33
- <Text className="zd:whitespace-nowrap zd:text-center zd:text-h3">
34
- {cryptoAmount}
35
- </Text>
36
- </Wrapper>
37
- {/* Token tile — straddles the card's top edge, centred. */}
38
- <div className="zd:absolute zd:top-0 zd:left-1/2 zd:flex zd:size-18.5 zd:-translate-x-1/2 zd:items-center zd:justify-center zd:rounded-3xl zd:border zd:border-white/20 zd:bg-offWhite/80 zd:backdrop-blur-[15px]">
39
- {tokenLogoUrl && (
40
- <img
41
- src={tokenLogoUrl}
42
- alt=""
43
- aria-hidden
44
- className="zd:size-10.5 zd:object-contain"
45
- />
46
- )}
47
- </div>
48
- </div>
49
- )
50
- }