Skip to content

Commit 4dbcbd1

Browse files
authored
feat(react): initial auth for <ConnectButton /> (#2793)
Co-authored-by: Manan Tank <manantankm@gmail.com>
1 parent 4eb6283 commit 4dbcbd1

File tree

11 files changed

+688
-12
lines changed

11 files changed

+688
-12
lines changed

.changeset/cuddly-rockets-study.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
**thirdweb/react**
5+
6+
Add a new optional `auth` prop to `<ConnectButton />` to allow specifying a SIWE auth flow after users connect their wallets.
7+
8+
```jsx
9+
<ConnectButton
10+
client={client}
11+
auth={{
12+
isLoggedIn: async (address) => {
13+
// check if the user is logged in by calling your server, etc.
14+
// then return a boolean value
15+
return true || false;
16+
},
17+
getLoginPayload: async ({ address, chainId }) => {
18+
// send the address (and optional chainId) to your server to generate the login payload for the user to sign
19+
// you can use the `generatePayload` function from `thirdweb/auth` to generate the payload
20+
// once you have retrieved the payload return it from this function
21+
22+
return // <the login payload here>
23+
},
24+
doLogin: async (loginParams) => {
25+
// send the login params to your server where you can validate them using the `verifyPayload` function
26+
// from `thirdweb/auth`
27+
// you can then set a cookie or return a token to save in local storage, etc
28+
// `isLoggedIn` will automatically get called again after this function resolves
29+
},
30+
doLogout: async () => {
31+
// do anything you need to do such as clearing cookies, etc when the user should be logged out
32+
// `isLoggedIn` will automatically get called again after this function resolves
33+
},
34+
}}
35+
/>
36+
```

packages/thirdweb/src/exports/react.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,6 @@ export {
9696
AutoConnect,
9797
type AutoConnectProps,
9898
} from "../react/core/hooks/connection/useAutoConnect.js";
99+
100+
// auth
101+
export { type SiweAuthOptions } from "../react/core/hooks/auth/useSiweAuth.js";

packages/thirdweb/src/extensions/marketplace/english-auctions/write/executeSale.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export function executeSale(
3535
contract: options.contract,
3636
auctionId: options.auctionId,
3737
});
38-
console.log("*** winning bid", winningBid);
3938
if (!winningBid) {
4039
throw new Error("Auction is still active");
4140
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import type { LoginPayload } from "../../../../auth/core/types.js";
3+
import type { VerifyLoginPayloadParams } from "../../../../auth/core/verify-login-payload.js";
4+
import { useActiveWallet } from "../../../core/hooks/wallets/wallet-hooks.js";
5+
6+
/**
7+
* Options for Setting up SIWE (Sign in with Ethereum) Authentication
8+
* @auth
9+
*/
10+
export type SiweAuthOptions = {
11+
// we pass address and chainId and retrieve a login payload (we do not care how)
12+
13+
/**
14+
* Method to get the login payload for given address and chainId
15+
* @param params - The parameters to get the login payload for.
16+
*/
17+
getLoginPayload: (params: {
18+
address: string;
19+
chainId: number;
20+
}) => Promise<LoginPayload>;
21+
22+
// we pass the login payload and signature and the developer passes this to the auth server however they want
23+
24+
/**
25+
* Method to login with the signed login payload
26+
* @param params
27+
*/
28+
doLogin: (params: VerifyLoginPayloadParams) => Promise<void>;
29+
30+
// we call this internally when a user explicitly disconnects their wallet
31+
32+
/**
33+
* Method to logout the user
34+
*/
35+
doLogout: () => Promise<void>;
36+
37+
// the developer specifies how to check if the user is logged in, this is called internally by the component
38+
39+
/**
40+
* Method to check if the user is logged in or not
41+
* @param address
42+
*/
43+
isLoggedIn: (address: string) => Promise<boolean>;
44+
};
45+
46+
/**
47+
* @internal
48+
*/
49+
export function useSiweAuth(authOptions?: SiweAuthOptions) {
50+
const activeWallet = useActiveWallet();
51+
const activeAccount = activeWallet?.getAccount();
52+
53+
const requiresAuth = !!authOptions;
54+
55+
const queryClient = useQueryClient();
56+
57+
const isLoggedInQuery = useQuery({
58+
queryKey: ["siwe_auth", "isLoggedIn", activeAccount?.address],
59+
enabled: requiresAuth && !!activeAccount?.address,
60+
queryFn: () => {
61+
// these cases should never be hit but just in case...
62+
if (!authOptions || !activeAccount?.address) {
63+
return false;
64+
}
65+
return authOptions.isLoggedIn(activeAccount.address);
66+
},
67+
});
68+
69+
const loginMutation = useMutation({
70+
mutationFn: async () => {
71+
if (!authOptions) {
72+
throw new Error("No auth options provided");
73+
}
74+
75+
if (!activeWallet) {
76+
throw new Error("No active wallet");
77+
}
78+
const chain = activeWallet.getChain();
79+
if (!chain) {
80+
throw new Error("No active chain");
81+
}
82+
if (!activeAccount) {
83+
throw new Error("No active account");
84+
}
85+
const [payload, { signLoginPayload }] = await Promise.all([
86+
authOptions.getLoginPayload({
87+
address: activeAccount.address,
88+
chainId: chain.id,
89+
}),
90+
// we lazy-load this because it's only needed when logging in
91+
import("../../../../auth/core/sign-login-payload.js"),
92+
]);
93+
94+
const signedPayload = await signLoginPayload({
95+
payload,
96+
account: activeAccount,
97+
});
98+
99+
return await authOptions.doLogin(signedPayload);
100+
},
101+
onSettled: () => {
102+
return queryClient.invalidateQueries({
103+
queryKey: ["siwe_auth", "isLoggedIn"],
104+
});
105+
},
106+
});
107+
108+
const logoutMutation = useMutation({
109+
mutationFn: async () => {
110+
if (!authOptions) {
111+
throw new Error("No auth options provided");
112+
}
113+
114+
return await authOptions.doLogout();
115+
},
116+
onSettled: () => {
117+
return queryClient.invalidateQueries({
118+
queryKey: ["siwe_auth", "isLoggedIn"],
119+
});
120+
},
121+
});
122+
123+
return {
124+
// is auth even enabled
125+
requiresAuth,
126+
127+
// login
128+
doLogin: loginMutation.mutateAsync,
129+
isLoggingIn: loginMutation.isPending,
130+
131+
// logout
132+
doLogout: logoutMutation.mutateAsync,
133+
isLoggingOut: logoutMutation.isPending,
134+
135+
// checking if logged in
136+
isLoggedIn: isLoggedInQuery.data,
137+
isLoading: isLoggedInQuery.isLoading,
138+
};
139+
}

packages/thirdweb/src/react/core/hooks/connection/useAutoConnect.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export function AutoConnect(props: AutoConnectProps) {
268268

269269
(async () => {
270270
isAutoConnecting.setValue(true);
271-
startAutoConnect();
271+
await startAutoConnect();
272272
isAutoConnecting.setValue(false);
273273
})();
274274
});

packages/thirdweb/src/react/core/providers/wallet-connection.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AppMetadata } from "../../../wallets/types.js";
77
import type { ConnectButton_connectModalOptions } from "../../web/ui/ConnectWallet/ConnectWalletProps.js";
88
import type { ConnectLocale } from "../../web/ui/ConnectWallet/locale/types.js";
99
import type { LocaleId } from "../../web/ui/types.js";
10+
import type { SiweAuthOptions } from "../hooks/auth/useSiweAuth.js";
1011

1112
export const ConnectUIContext = /* @__PURE__ */ createContext<{
1213
wallets: Wallet[];
@@ -27,4 +28,5 @@ export const ConnectUIContext = /* @__PURE__ */ createContext<{
2728
connectModal: Omit<ConnectButton_connectModalOptions, "size"> & {
2829
size: "compact" | "wide";
2930
};
31+
auth?: SiweAuthOptions;
3032
} | null>(null);

packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectWallet.tsx

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import styled from "@emotion/styled";
2-
import { useMemo } from "react";
2+
import { useEffect, useMemo, useState } from "react";
3+
import { useSiweAuth } from "../../../core/hooks/auth/useSiweAuth.js";
34
import { AutoConnect } from "../../../core/hooks/connection/useAutoConnect.js";
45
import {
56
useActiveAccount,
@@ -12,15 +13,20 @@ import {
1213
} from "../../providers/wallet-ui-states-provider.js";
1314
import { canFitWideModal } from "../../utils/canFitWideModal.js";
1415
import { getDefaultWallets } from "../../wallets/defaultWallets.js";
16+
import { Modal } from "../components/Modal.js";
1517
import { Spinner } from "../components/Spinner.js";
18+
import { Container } from "../components/basic.js";
1619
import { Button } from "../components/buttons.js";
1720
import { fadeInAnimation } from "../design-system/animations.js";
21+
import { iconSize } from "../design-system/index.js";
1822
import type { ConnectButtonProps } from "./ConnectWalletProps.js";
1923
import { ConnectedWalletDetails } from "./Details.js";
2024
import ConnectModal from "./Modal/ConnectModal.js";
2125
import { defaultTokens } from "./defaultTokens.js";
26+
import { LockIcon } from "./icons/LockIcon.js";
2227
import { useConnectLocale } from "./locale/getConnectLocale.js";
2328
import type { ConnectLocale } from "./locale/types.js";
29+
import { SignatureScreen } from "./screens/SignatureScreen.js";
2430

2531
const TW_CONNECT_WALLET = "tw-connect-wallet";
2632

@@ -40,7 +46,10 @@ const TW_CONNECT_WALLET = "tw-connect-wallet";
4046
* @component
4147
*/
4248
export function ConnectButton(props: ConnectButtonProps) {
43-
const wallets = props.wallets || getDefaultWallets();
49+
const wallets = useMemo(
50+
() => props.wallets || getDefaultWallets(),
51+
[props.wallets],
52+
);
4453
const localeQuery = useConnectLocale(props.locale || "en_US");
4554

4655
const autoConnectComp = props.autoConnect !== false && (
@@ -100,6 +109,7 @@ export function ConnectButton(props: ConnectButtonProps) {
100109
: props.connectModal?.size || "wide",
101110
},
102111
onConnect: props.onConnect,
112+
auth: props.auth,
103113
}}
104114
>
105115
<WalletUIStatesProvider theme={props.theme}>
@@ -117,6 +127,16 @@ function ConnectButtonInner(
117127
},
118128
) {
119129
const activeAccount = useActiveAccount();
130+
const siweAuth = useSiweAuth(props.auth);
131+
const [showSignatureModal, setShowSignatureModal] = useState(false);
132+
133+
// if wallet gets disconnected suddently, close the signature modal if it's open
134+
useEffect(() => {
135+
if (!activeAccount) {
136+
setShowSignatureModal(false);
137+
}
138+
}, [activeAccount]);
139+
120140
const theme = props.theme || "dark";
121141
const connectionStatus = useActiveWalletConnectionStatus();
122142
const locale = props.connectLocale;
@@ -180,14 +200,84 @@ function ConnectButtonInner(
180200
);
181201
}
182202

203+
if (siweAuth.requiresAuth) {
204+
// loading state if loading
205+
// TODO: figure out a way to consolidate the loading states with the ones from locale loading
206+
if (siweAuth.isLoading) {
207+
return (
208+
<AnimatedButton
209+
disabled={true}
210+
className={`${
211+
props.connectButton?.className || ""
212+
} ${TW_CONNECT_WALLET}`}
213+
variant="primary"
214+
type="button"
215+
style={{
216+
minWidth: "140px",
217+
...props.connectButton?.style,
218+
}}
219+
>
220+
<Spinner size="sm" color="primaryButtonText" />
221+
</AnimatedButton>
222+
);
223+
}
224+
// sign in button + modal if *not* loading and *not* logged in
225+
if (!siweAuth.isLoggedIn) {
226+
return (
227+
<>
228+
<Button
229+
variant="primary"
230+
type="button"
231+
onClick={() => {
232+
setShowSignatureModal(true);
233+
}}
234+
className={props.signInButton?.className}
235+
style={{
236+
minWidth: "140px",
237+
...props.signInButton?.style,
238+
}}
239+
>
240+
{siweAuth.isLoggingIn ? (
241+
<Spinner size="sm" color="primaryButtonText" />
242+
) : (
243+
<Container flex="row" center="y" gap="sm">
244+
<LockIcon size={iconSize.sm} />
245+
<span> {props.signInButton?.label || locale.signIn} </span>
246+
</Container>
247+
)}
248+
</Button>
249+
<Modal
250+
size="compact"
251+
open={showSignatureModal}
252+
setOpen={setShowSignatureModal}
253+
>
254+
<SignatureScreen
255+
client={props.client}
256+
connectLocale={locale}
257+
modalSize="compact"
258+
termsOfServiceUrl={props.connectModal?.termsOfServiceUrl}
259+
privacyPolicyUrl={props.connectModal?.privacyPolicyUrl}
260+
onDone={() => setShowSignatureModal(false)}
261+
auth={props.auth}
262+
/>
263+
</Modal>
264+
</>
265+
);
266+
}
267+
// otherwise, show the details button
268+
}
269+
183270
return (
184271
<ConnectedWalletDetails
185272
theme={theme}
186273
detailsButton={props.detailsButton}
187274
detailsModal={props.detailsModal}
188275
supportedTokens={supportedTokens}
189276
onDisconnect={() => {
190-
// no op
277+
// logout on explicit disconnect!
278+
if (siweAuth.requiresAuth) {
279+
siweAuth.doLogout();
280+
}
191281
}}
192282
chains={props?.chains || []}
193283
chain={props.chain}

0 commit comments

Comments
 (0)