-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSignInButton.tsx
More file actions
276 lines (254 loc) · 10.4 KB
/
Copy pathSignInButton.tsx
File metadata and controls
276 lines (254 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"use client";
// Sign-in entry point. Privy owns the connect modal (email / Google /
// Twitter / wallet); once the user is authenticated and a wallet address
// is available via wagmi, we run the existing SIWE → /api/auth/verify
// flow to set the iron-session cookie. The on-chain code elsewhere
// (PublishActions, builder write calls) keeps using wagmi hooks
// unchanged because Privy bridges the embedded / external wallet into
// the wagmi connector list.
import { usePrivy, useWallets } from "@privy-io/react-auth";
import { useSetActiveWallet } from "@privy-io/wagmi";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { SiweMessage } from "siwe";
import { useAccount, useSignMessage } from "wagmi";
type Props = {
// CSS class applied to the outer button when not connected. Lets us reuse
// the .hype-btn styles from globals.css for a single canonical look.
className?: string;
// After successful sign-in, push the router here. Default '/dashboard'.
redirectTo?: string;
// Label shown on the button when the user is not yet connected. Other
// states (signing, error, signed-in) keep their canonical text so the
// user always knows what action will happen next.
label?: string;
};
type Status = "idle" | "loading" | "signed-in" | "error";
export function SignInButton({
className = "hype-btn primary",
redirectTo = "/dashboard",
label = "Get access →",
}: Props) {
const router = useRouter();
const { ready, authenticated, login, logout } = usePrivy();
const { wallets } = useWallets();
const { setActiveWallet } = useSetActiveWallet();
const { address, chainId, isConnected, connector } = useAccount();
const { signMessageAsync } = useSignMessage();
// Once Privy is authenticated, prefer the embedded wallet as the wagmi
// active connector. Without this, if the user has MetaMask installed AND
// logs in via Gmail / email / X, wagmi will keep MetaMask as the active
// connector — so `useSignMessage` would dispatch the SIWE prompt to
// MetaMask instead of the freshly-created embedded wallet, and the app
// would end up signed-in as the MetaMask address.
//
// We only force the switch when the user has an embedded wallet that
// isn't already the active connector. Pure-wallet logins (MetaMask /
// Rabby) skip this because `createOnLogin: "users-without-wallets"`
// never gives them an embedded wallet.
useEffect(() => {
if (!ready || !authenticated) return;
const embedded = wallets.find((w) => w.walletClientType === "privy");
if (!embedded) return;
// Already active? Nothing to do. wagmi's connector id for Privy
// embedded wallets is "io.privy.wallet" — match on that OR on the
// address matching the embedded wallet to be resilient to renames.
const activeIsEmbedded =
connector?.id === "io.privy.wallet" ||
address?.toLowerCase() === embedded.address.toLowerCase();
if (activeIsEmbedded) return;
void setActiveWallet(embedded);
}, [ready, authenticated, wallets, connector?.id, address, setActiveWallet]);
const [status, setStatus] = useState<Status>("idle");
const [sessionAddress, setSessionAddress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Per-address attempt guard. Auto-triggering signIn() on connect is the
// obvious UX win, but a naive `useEffect([isConnected, address])`
// re-fires on every status change too — which means after a failed
// verify (status → "error"), the effect re-runs, fetches a new nonce
// (overwriting the session.nonce), and races the next signMessage
// → 401 forever. The fix is to remember which address we've already
// tried for in this tab, and only auto-trigger ONCE per connection.
// Cleared when the wallet disconnects.
const handledAddressRef = useRef<string | null>(null);
// Hydrate session state on mount so a returning visitor sees their
// logged-in status without having to reconnect.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me", { cache: "no-store" })
.then((r) => r.json())
.then((d: { address: string | null }) => {
if (cancelled) return;
if (d.address) {
setSessionAddress(d.address);
setStatus("signed-in");
}
})
.catch(() => {
// Silent: missing/invalid session just leaves status at 'idle'.
});
return () => {
cancelled = true;
};
}, []);
const signIn = useCallback(async () => {
if (!address || !chainId) return;
if (status === "loading") return;
setStatus("loading");
setError(null);
try {
const nonceRes = await fetch("/api/auth/nonce", { cache: "no-store" });
const { nonce } = (await nonceRes.json()) as { nonce: string };
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in to HypeNode.",
uri: window.location.origin,
version: "1",
chainId,
nonce,
});
const prepared = message.prepareMessage();
const signature = await signMessageAsync({ message: prepared });
const verifyRes = await fetch("/api/auth/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: prepared, signature }),
});
const verifyJson = (await verifyRes.json()) as {
ok: boolean;
address?: string;
role?: "indexer" | "publisher" | null;
error?: string;
};
if (!verifyRes.ok || !verifyJson.ok) {
throw new Error(verifyJson.error ?? "verify failed");
}
setSessionAddress(verifyJson.address ?? address.toLowerCase());
setStatus("signed-in");
// Routing decision:
// - Caller passed an explicit (non-default) redirectTo → honour it.
// - User has a saved role → land on that role's home.
// - First-time user with no saved role → onboarding picker.
let dest = redirectTo;
if (redirectTo === "/dashboard") {
if (verifyJson.role === "publisher") dest = "/publisher/radar";
else if (verifyJson.role === "indexer") dest = "/dashboard";
else dest = "/onboarding/role";
}
router.push(dest);
router.refresh();
} catch (err) {
setStatus("error");
setError((err as Error).message);
}
}, [address, chainId, status, signMessageAsync, router, redirectTo]);
// Reset transient sign-in state when the wallet disconnects so the next
// connection starts from a clean slate (otherwise an old "error" sticks
// around and the user sees a Retry button on a fresh wallet).
useEffect(() => {
if (!isConnected && status !== "signed-in") {
setStatus("idle");
setError(null);
handledAddressRef.current = null;
}
}, [isConnected, status]);
// Auto-trigger SIWE the moment Privy reports authenticated AND wagmi
// surfaces an address — so the user only has to click "Get access"
// once, regardless of whether they used email / Google / Twitter or
// an external wallet. The handledAddressRef + status guards together
// prevent the retry-loop bug: we only fire when status is exactly
// "idle" AND we haven't already tried this address. After a failure
// (status → "error"), the user explicitly retries via the Retry
// button; the auto-trigger does NOT fire on error states.
//
// Important: when the user has an embedded wallet (email / social
// login), we wait until wagmi's active connector is actually the
// embedded wallet before triggering SIWE. Otherwise the prior
// effect's setActiveWallet() races with this one — the SIWE prompt
// would be dispatched to MetaMask while wagmi is still mid-swap, and
// the app would end up logged-in as the MetaMask address.
useEffect(() => {
if (!ready || !authenticated) return;
if (!isConnected || !address) return;
if (status !== "idle") return;
if (handledAddressRef.current === address) return;
const embedded = wallets.find((w) => w.walletClientType === "privy");
if (embedded && address.toLowerCase() !== embedded.address.toLowerCase()) {
// Embedded wallet exists but isn't active yet — let the
// setActiveWallet effect above swap it in first; this effect will
// re-run when `address` updates to the embedded one.
return;
}
handledAddressRef.current = address;
signIn();
}, [ready, authenticated, isConnected, address, wallets, status, signIn]);
const signOut = useCallback(async () => {
await fetch("/api/auth/logout", { method: "POST" });
setSessionAddress(null);
setStatus("idle");
// Privy's logout drops both its own session AND disconnects the
// wagmi-bridged wallet, so we don't need a separate `disconnect()`.
await logout();
router.refresh();
}, [logout, router]);
if (!ready) {
return (
<button type="button" className={className} disabled>
Loading…
</button>
);
}
if (!authenticated || !isConnected) {
return (
<button type="button" className={className} onClick={() => login()}>
{label}
</button>
);
}
if (status === "loading") {
return (
<button type="button" className={className} disabled>
Signing in…
</button>
);
}
if (status === "error") {
return (
<button
type="button"
className={className}
onClick={signIn}
title={error ?? undefined}
>
Retry sign-in →
</button>
);
}
if (status === "signed-in" && sessionAddress) {
return (
<div style={{ display: "inline-flex", gap: 8, alignItems: "center" }}>
<a className="hype-btn" href={redirectTo}>
{short(sessionAddress)}
</a>
<button type="button" className="hype-btn ghost" onClick={signOut}>
Sign out
</button>
</div>
);
}
// Connected but not yet signed in. With auto-trigger this state is
// brief (just the moment between Privy authenticate and the first
// setStatus("loading")), but we render a manual button as fallback
// — for example if the auto-trigger handler errored before even
// setting status, or if the user dismissed the wallet's signature
// prompt and we want them to be able to retry without disconnecting.
return (
<button type="button" className={className} onClick={signIn}>
Sign message →
</button>
);
}
function short(addr: string): string {
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
}