Skip to content

Commit 03b6d0d

Browse files
committed
feat: add unlinking for in app wallet accounts (#5604)
https://linear.app/thirdweb/issue/CNCT-2497/unlink-auth-method-for-enclave-wallets <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces functionality to unlink accounts from the in-app wallet, allowing users to manage multiple linked profiles more effectively. ### Detailed summary - Added `UnlinkParams` type for unlinking profiles. - Implemented `unlinkProfile` method in `InAppNativeConnector` and `InAppWebConnector`. - Updated `unlinkAccount` function to handle profile unlinking. - Added `useUnlinkProfile` hook for React. - Enhanced `LinkedProfilesScreen` to include unlink buttons when multiple profiles exist. - Added tests for unlinking functionality. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent f7e6041 commit 03b6d0d

File tree

21 files changed

+716
-47
lines changed

21 files changed

+716
-47
lines changed

.changeset/strong-snails-jam.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Support the ability to unlink accounts for in app wallet with more than 1 linked account.
6+
7+
It's supported out of the box in the connect UI.
8+
9+
For typescript users, the following code snippet is a simple example of how it'd work.
10+
11+
```typescript
12+
import { inAppWallet } from "thirdweb/wallets";
13+
14+
const wallet = inAppWallet();
15+
wallet.connect({ strategy: "google" });
16+
17+
const profiles = await getProfiles({
18+
client,
19+
});
20+
21+
const updatedProfiles = await unlinkProfile({
22+
client,
23+
profileToUnlink: profiles[1],// assuming there is more than 1 profile linked to the user.
24+
});
25+
```

packages/thirdweb/src/exports/react.native.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export { useCallsStatus } from "../react/core/hooks/wallets/useCallsStatus.js";
2727
export { useWalletBalance } from "../react/core/hooks/others/useWalletBalance.js";
2828
export { useProfiles } from "../react/native/hooks/wallets/useProfiles.js";
2929
export { useLinkProfile } from "../react/native/hooks/wallets/useLinkProfile.js";
30+
export { useUnlinkProfile } from "../react/native/hooks/wallets/useUnlinkProfile.js";
3031

3132
// contract
3233
export { useReadContract } from "../react/core/hooks/contract/useReadContract.js";

packages/thirdweb/src/exports/react.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export { useCallsStatus } from "../react/core/hooks/wallets/useCallsStatus.js";
5858
export { useWalletBalance } from "../react/core/hooks/others/useWalletBalance.js";
5959
export { useProfiles } from "../react/web/hooks/wallets/useProfiles.js";
6060
export { useLinkProfile } from "../react/web/hooks/wallets/useLinkProfile.js";
61+
export { useUnlinkProfile } from "../react/web/hooks/wallets/useUnlinkProfile.js";
6162

6263
// chain hooks
6364
export { useChainMetadata } from "../react/core/hooks/others/useChainQuery.js";

packages/thirdweb/src/exports/wallets.native.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export {
9898
getUserPhoneNumber,
9999
getProfiles,
100100
linkProfile,
101+
unlinkProfile,
101102
} from "../wallets/in-app/native/auth/index.js";
102103
export type { Profile } from "../wallets/in-app/core/authentication/types.js";
103104
export const authenticateWithRedirect = () => {

packages/thirdweb/src/exports/wallets.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export {
106106
getUserPhoneNumber,
107107
getProfiles,
108108
linkProfile,
109+
unlinkProfile,
109110
} from "../wallets/in-app/web/lib/auth/index.js";
110111
export type { Profile } from "../wallets/in-app/core/authentication/types.js";
111112

packages/thirdweb/src/exports/wallets/in-app.native.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
getUserPhoneNumber,
1010
getProfiles,
1111
linkProfile,
12+
unlinkProfile,
1213
} from "../../wallets/in-app/native/auth/index.js";
1314

1415
export type { GetAuthenticatedUserParams } from "../../wallets/in-app/core/authentication/types.js";

packages/thirdweb/src/exports/wallets/in-app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
getUserPhoneNumber,
1010
getProfiles,
1111
linkProfile,
12+
unlinkProfile,
1213
} from "../../wallets/in-app/web/lib/auth/index.js";
1314

1415
export type { GetAuthenticatedUserParams } from "../../wallets/in-app/core/authentication/types.js";
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { act, renderHook } from "@testing-library/react";
3+
import type React from "react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { TEST_CLIENT } from "~test/test-clients.js";
6+
import { useConnectedWallets } from "../../../../react/core/hooks/wallets/useConnectedWallets.js";
7+
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
8+
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
9+
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
10+
import { useUnlinkProfile } from "./useUnlinkProfile.js";
11+
12+
vi.mock("../../../../wallets/in-app/web/lib/auth/index.js");
13+
vi.mock("../../../core/hooks/wallets/useConnectedWallets.js");
14+
15+
describe("useUnlinkProfile", () => {
16+
const queryClient = new QueryClient();
17+
const wrapper = ({ children }: { children: React.ReactNode }) => (
18+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
19+
);
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.spyOn(queryClient, "invalidateQueries");
24+
});
25+
26+
const mockProfile = {} as unknown as Profile;
27+
it("should call unlinkProfile with correct parameters", async () => {
28+
vi.mocked(useConnectedWallets).mockReturnValue([]);
29+
30+
const { result } = renderHook(() => useUnlinkProfile(), {
31+
wrapper,
32+
});
33+
const mutationFn = result.current.mutateAsync;
34+
35+
await act(async () => {
36+
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
37+
});
38+
39+
expect(unlinkProfile).toHaveBeenCalledWith({
40+
client: TEST_CLIENT,
41+
ecosystem: undefined,
42+
profileToUnlink: mockProfile,
43+
});
44+
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
45+
queryKey: ["profiles"],
46+
});
47+
});
48+
49+
it("should include ecosystem if ecosystem wallet is found", async () => {
50+
const mockWallet = {
51+
id: "ecosystem.wallet-id",
52+
getConfig: () => ({ partnerId: "partner-id" }),
53+
} as unknown as Wallet;
54+
vi.mocked(useConnectedWallets).mockReturnValue([mockWallet]);
55+
56+
const { result } = renderHook(() => useUnlinkProfile(), {
57+
wrapper,
58+
});
59+
const mutationFn = result.current.mutateAsync;
60+
61+
await act(async () => {
62+
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
63+
});
64+
65+
expect(unlinkProfile).toHaveBeenCalledWith({
66+
client: TEST_CLIENT,
67+
ecosystem: {
68+
id: mockWallet.id,
69+
partnerId: (mockWallet as Wallet<`ecosystem.${string}`>).getConfig()
70+
?.partnerId,
71+
},
72+
profileToUnlink: mockProfile,
73+
});
74+
});
75+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import type { ThirdwebClient } from "../../../../client/client.js";
3+
import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js";
4+
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
5+
import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js";
6+
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
7+
import { useConnectedWallets } from "../../../core/hooks/wallets/useConnectedWallets.js";
8+
9+
/**
10+
* Unlinks a web2 or web3 profile currently connected in-app or ecosystem account.
11+
* **When a profile is unlinked from the account, it will no longer be able to be used to sign into the account.**
12+
*
13+
* @example
14+
*
15+
* ### Unlinking an email account
16+
*
17+
* ```jsx
18+
* import { useUnlinkProfile } from "thirdweb/react";
19+
*
20+
* const { data: connectedProfiles, isLoading } = useProfiles({
21+
* client: props.client,
22+
* });
23+
* const { mutate: unlinkProfile } = useUnlinkProfile();
24+
*
25+
* const onClick = () => {
26+
* unlinkProfile({
27+
* client,
28+
* // Select any other profile you want to unlink
29+
* profileToUnlink: connectedProfiles[1]
30+
* });
31+
* };
32+
* ```
33+
*
34+
* @wallet
35+
*/
36+
export function useUnlinkProfile() {
37+
const wallets = useConnectedWallets();
38+
const queryClient = useQueryClient();
39+
return useMutation({
40+
mutationFn: async ({
41+
client,
42+
profileToUnlink,
43+
}: { client: ThirdwebClient; profileToUnlink: Profile }) => {
44+
const ecosystemWallet = wallets.find((w) => isEcosystemWallet(w));
45+
const ecosystem: Ecosystem | undefined = ecosystemWallet
46+
? {
47+
id: ecosystemWallet.id,
48+
partnerId: ecosystemWallet.getConfig()?.partnerId,
49+
}
50+
: undefined;
51+
52+
await unlinkProfile({
53+
client,
54+
ecosystem,
55+
profileToUnlink,
56+
});
57+
},
58+
onSuccess: () => {
59+
queryClient.invalidateQueries({ queryKey: ["profiles"] });
60+
},
61+
});
62+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { act, renderHook } from "@testing-library/react";
3+
import type React from "react";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { TEST_CLIENT } from "~test/test-clients.js";
6+
import { useConnectedWallets } from "../../../../react/core/hooks/wallets/useConnectedWallets.js";
7+
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
8+
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
9+
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
10+
import { useUnlinkProfile } from "./useUnlinkProfile.js";
11+
12+
vi.mock("../../../../wallets/in-app/web/lib/auth/index.js");
13+
vi.mock("../../../core/hooks/wallets/useConnectedWallets.js");
14+
15+
describe("useUnlinkProfile", () => {
16+
const queryClient = new QueryClient();
17+
const wrapper = ({ children }: { children: React.ReactNode }) => (
18+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
19+
);
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.spyOn(queryClient, "invalidateQueries");
24+
});
25+
26+
const mockProfile = {} as unknown as Profile;
27+
it("should call unlinkProfile with correct parameters", async () => {
28+
vi.mocked(useConnectedWallets).mockReturnValue([]);
29+
30+
const { result } = renderHook(() => useUnlinkProfile(), {
31+
wrapper,
32+
});
33+
const mutationFn = result.current.mutateAsync;
34+
35+
await act(async () => {
36+
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
37+
});
38+
39+
expect(unlinkProfile).toHaveBeenCalledWith({
40+
client: TEST_CLIENT,
41+
ecosystem: undefined,
42+
profileToUnlink: mockProfile,
43+
});
44+
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
45+
queryKey: ["profiles"],
46+
});
47+
});
48+
49+
it("should include ecosystem if ecosystem wallet is found", async () => {
50+
const mockWallet = {
51+
id: "ecosystem.wallet-id",
52+
getConfig: () => ({ partnerId: "partner-id" }),
53+
} as unknown as Wallet;
54+
vi.mocked(useConnectedWallets).mockReturnValue([mockWallet]);
55+
56+
const { result } = renderHook(() => useUnlinkProfile(), {
57+
wrapper,
58+
});
59+
const mutationFn = result.current.mutateAsync;
60+
61+
await act(async () => {
62+
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
63+
});
64+
65+
expect(unlinkProfile).toHaveBeenCalledWith({
66+
client: TEST_CLIENT,
67+
ecosystem: {
68+
id: mockWallet.id,
69+
partnerId: (mockWallet as Wallet<`ecosystem.${string}`>).getConfig()
70+
?.partnerId,
71+
},
72+
profileToUnlink: mockProfile,
73+
});
74+
});
75+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import type { ThirdwebClient } from "../../../../client/client.js";
3+
import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js";
4+
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
5+
import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js";
6+
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
7+
import { useConnectedWallets } from "../../../core/hooks/wallets/useConnectedWallets.js";
8+
9+
/**
10+
* Unlinks a web2 or web3 profile currently connected in-app or ecosystem account.
11+
* **When a profile is unlinked from the account, it will no longer be able to be used to sign into the account.**
12+
*
13+
* @example
14+
*
15+
* ### Unlinking an email account
16+
*
17+
* ```jsx
18+
* import { useUnlinkProfile } from "thirdweb/react";
19+
*
20+
* const { data: connectedProfiles, isLoading } = useProfiles({
21+
* client: props.client,
22+
* });
23+
* const { mutate: unlinkProfile } = useUnlinkProfile();
24+
*
25+
* const onClick = () => {
26+
* unlinkProfile({
27+
* client,
28+
* // Select any other profile you want to unlink
29+
* profileToUnlink: connectedProfiles[1]
30+
* });
31+
* };
32+
* ```
33+
*
34+
* @wallet
35+
*/
36+
export function useUnlinkProfile() {
37+
const wallets = useConnectedWallets();
38+
const queryClient = useQueryClient();
39+
return useMutation({
40+
mutationFn: async ({
41+
client,
42+
profileToUnlink,
43+
}: { client: ThirdwebClient; profileToUnlink: Profile }) => {
44+
const ecosystemWallet = wallets.find((w) => isEcosystemWallet(w));
45+
const ecosystem: Ecosystem | undefined = ecosystemWallet
46+
? {
47+
id: ecosystemWallet.id,
48+
partnerId: ecosystemWallet.getConfig()?.partnerId,
49+
}
50+
: undefined;
51+
52+
await unlinkProfile({
53+
client,
54+
ecosystem,
55+
profileToUnlink,
56+
});
57+
},
58+
onSuccess: () => {
59+
queryClient.invalidateQueries({ queryKey: ["profiles"] });
60+
},
61+
});
62+
}

packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,5 +131,30 @@ describe("LinkedProfilesScreen", () => {
131131
render(<LinkedProfilesScreen {...mockProps} />);
132132
expect(screen.queryByText("Guest")).not.toBeInTheDocument();
133133
});
134+
135+
it("should render unlink button when there are multiple profiles", () => {
136+
vi.mocked(useProfiles).mockReturnValue({
137+
data: [
138+
{ type: "email", details: { email: "test@example.com" } },
139+
{ type: "google", details: { email: "google@example.com" } },
140+
],
141+
isLoading: false,
142+
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
143+
} as any);
144+
145+
render(<LinkedProfilesScreen {...mockProps} />);
146+
expect(screen.getAllByLabelText("Unlink")).toHaveLength(2);
147+
});
148+
149+
it("should not render unlink button when there is only one profile", () => {
150+
vi.mocked(useProfiles).mockReturnValue({
151+
data: [{ type: "email", details: { email: "test@example.com" } }],
152+
isLoading: false,
153+
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
154+
} as any);
155+
156+
render(<LinkedProfilesScreen {...mockProps} />);
157+
expect(screen.queryByLabelText("Unlink")).not.toBeInTheDocument();
158+
});
134159
});
135160
});

0 commit comments

Comments
 (0)