Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/strong-snails-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"thirdweb": minor
---

Support the ability to unlink accounts for in app wallet with more than 1 linked account.

It's supported out of the box in the connect UI.

For typescript users, the following code snippet is a simple example of how it'd work.

```typescript
import { inAppWallet } from "thirdweb/wallets";

const wallet = inAppWallet();
wallet.connect({ strategy: "google" });

const profiles = await getProfiles({
client,
});

const updatedProfiles = await unlinkProfile({
client,
profileToUnlink: profiles[1],// assuming there is more than 1 profile linked to the user.
});
```
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/react.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { useCallsStatus } from "../react/core/hooks/wallets/useCallsStatus.js";
export { useWalletBalance } from "../react/core/hooks/others/useWalletBalance.js";
export { useProfiles } from "../react/native/hooks/wallets/useProfiles.js";
export { useLinkProfile } from "../react/native/hooks/wallets/useLinkProfile.js";
export { useUnlinkProfile } from "../react/native/hooks/wallets/useUnlinkProfile.js";

// contract
export { useReadContract } from "../react/core/hooks/contract/useReadContract.js";
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export { useCallsStatus } from "../react/core/hooks/wallets/useCallsStatus.js";
export { useWalletBalance } from "../react/core/hooks/others/useWalletBalance.js";
export { useProfiles } from "../react/web/hooks/wallets/useProfiles.js";
export { useLinkProfile } from "../react/web/hooks/wallets/useLinkProfile.js";
export { useUnlinkProfile } from "../react/web/hooks/wallets/useUnlinkProfile.js";

// chain hooks
export { useChainMetadata } from "../react/core/hooks/others/useChainQuery.js";
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/wallets.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export {
getUserPhoneNumber,
getProfiles,
linkProfile,
unlinkProfile,
} from "../wallets/in-app/native/auth/index.js";
export type { Profile } from "../wallets/in-app/core/authentication/types.js";
export const authenticateWithRedirect = () => {
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export {
getUserPhoneNumber,
getProfiles,
linkProfile,
unlinkProfile,
} from "../wallets/in-app/web/lib/auth/index.js";
export type { Profile } from "../wallets/in-app/core/authentication/types.js";

Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/wallets/in-app.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
getUserPhoneNumber,
getProfiles,
linkProfile,
unlinkProfile,
} from "../../wallets/in-app/native/auth/index.js";

export type { GetAuthenticatedUserParams } from "../../wallets/in-app/core/authentication/types.js";
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/wallets/in-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
getUserPhoneNumber,
getProfiles,
linkProfile,
unlinkProfile,
} from "../../wallets/in-app/web/lib/auth/index.js";

export type { GetAuthenticatedUserParams } from "../../wallets/in-app/core/authentication/types.js";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_CLIENT } from "~test/test-clients.js";
import { useConnectedWallets } from "../../../../react/core/hooks/wallets/useConnectedWallets.js";
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
import { useUnlinkProfile } from "./useUnlinkProfile.js";

vi.mock("../../../../wallets/in-app/web/lib/auth/index.js");
vi.mock("../../../core/hooks/wallets/useConnectedWallets.js");

describe("useUnlinkProfile", () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(queryClient, "invalidateQueries");
});

const mockProfile = {} as unknown as Profile;
it("should call unlinkProfile with correct parameters", async () => {
vi.mocked(useConnectedWallets).mockReturnValue([]);

const { result } = renderHook(() => useUnlinkProfile(), {
wrapper,
});
const mutationFn = result.current.mutateAsync;

await act(async () => {
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
});

expect(unlinkProfile).toHaveBeenCalledWith({
client: TEST_CLIENT,
ecosystem: undefined,
profileToUnlink: mockProfile,
});
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["profiles"],
});
});

it("should include ecosystem if ecosystem wallet is found", async () => {
const mockWallet = {
id: "ecosystem.wallet-id",
getConfig: () => ({ partnerId: "partner-id" }),
} as unknown as Wallet;
vi.mocked(useConnectedWallets).mockReturnValue([mockWallet]);

const { result } = renderHook(() => useUnlinkProfile(), {
wrapper,
});
const mutationFn = result.current.mutateAsync;

await act(async () => {
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
});

expect(unlinkProfile).toHaveBeenCalledWith({
client: TEST_CLIENT,
ecosystem: {
id: mockWallet.id,
partnerId: (mockWallet as Wallet<`ecosystem.${string}`>).getConfig()
?.partnerId,
},
profileToUnlink: mockProfile,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ThirdwebClient } from "../../../../client/client.js";
import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js";
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js";
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
import { useConnectedWallets } from "../../../core/hooks/wallets/useConnectedWallets.js";

/**
* Unlinks a web2 or web3 profile currently connected in-app or ecosystem account.
* **When a profile is unlinked from the account, it will no longer be able to be used to sign into the account.**
*
* @example
*
* ### Unlinking an email account
*
* ```jsx
* import { useUnlinkProfile } from "thirdweb/react";
*
* const { data: connectedProfiles, isLoading } = useProfiles({
* client: props.client,
* });
* const { mutate: unlinkProfile } = useUnlinkProfile();
*
* const onClick = () => {
* unlinkProfile({
* client,
* // Select any other profile you want to unlink
* profileToUnlink: connectedProfiles[1]
* });
* };
* ```
*
* @wallet
*/
export function useUnlinkProfile() {
const wallets = useConnectedWallets();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
client,
profileToUnlink,
}: { client: ThirdwebClient; profileToUnlink: Profile }) => {
const ecosystemWallet = wallets.find((w) => isEcosystemWallet(w));
const ecosystem: Ecosystem | undefined = ecosystemWallet
? {
id: ecosystemWallet.id,
partnerId: ecosystemWallet.getConfig()?.partnerId,
}
: undefined;

await unlinkProfile({
client,
ecosystem,
profileToUnlink,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_CLIENT } from "~test/test-clients.js";
import { useConnectedWallets } from "../../../../react/core/hooks/wallets/useConnectedWallets.js";
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
import type { Wallet } from "../../../../wallets/interfaces/wallet.js";
import { useUnlinkProfile } from "./useUnlinkProfile.js";

vi.mock("../../../../wallets/in-app/web/lib/auth/index.js");
vi.mock("../../../core/hooks/wallets/useConnectedWallets.js");

describe("useUnlinkProfile", () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(queryClient, "invalidateQueries");
});

const mockProfile = {} as unknown as Profile;
it("should call unlinkProfile with correct parameters", async () => {
vi.mocked(useConnectedWallets).mockReturnValue([]);

const { result } = renderHook(() => useUnlinkProfile(), {
wrapper,
});
const mutationFn = result.current.mutateAsync;

await act(async () => {
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
});

expect(unlinkProfile).toHaveBeenCalledWith({
client: TEST_CLIENT,
ecosystem: undefined,
profileToUnlink: mockProfile,
});
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["profiles"],
});
});

it("should include ecosystem if ecosystem wallet is found", async () => {
const mockWallet = {
id: "ecosystem.wallet-id",
getConfig: () => ({ partnerId: "partner-id" }),
} as unknown as Wallet;
vi.mocked(useConnectedWallets).mockReturnValue([mockWallet]);

const { result } = renderHook(() => useUnlinkProfile(), {
wrapper,
});
const mutationFn = result.current.mutateAsync;

await act(async () => {
await mutationFn({ client: TEST_CLIENT, profileToUnlink: mockProfile });
});

expect(unlinkProfile).toHaveBeenCalledWith({
client: TEST_CLIENT,
ecosystem: {
id: mockWallet.id,
partnerId: (mockWallet as Wallet<`ecosystem.${string}`>).getConfig()
?.partnerId,
},
profileToUnlink: mockProfile,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ThirdwebClient } from "../../../../client/client.js";
import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js";
import type { Profile } from "../../../../wallets/in-app/core/authentication/types.js";
import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js";
import { unlinkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js";
import { useConnectedWallets } from "../../../core/hooks/wallets/useConnectedWallets.js";

/**
* Unlinks a web2 or web3 profile currently connected in-app or ecosystem account.
* **When a profile is unlinked from the account, it will no longer be able to be used to sign into the account.**
*
* @example
*
* ### Unlinking an email account
*
* ```jsx
* import { useUnlinkProfile } from "thirdweb/react";
*
* const { data: connectedProfiles, isLoading } = useProfiles({
* client: props.client,
* });
* const { mutate: unlinkProfile } = useUnlinkProfile();
*
* const onClick = () => {
* unlinkProfile({
* client,
* // Select any other profile you want to unlink
* profileToUnlink: connectedProfiles[1]
* });
* };
* ```
*
* @wallet
*/
export function useUnlinkProfile() {
const wallets = useConnectedWallets();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
client,
profileToUnlink,
}: { client: ThirdwebClient; profileToUnlink: Profile }) => {
const ecosystemWallet = wallets.find((w) => isEcosystemWallet(w));
const ecosystem: Ecosystem | undefined = ecosystemWallet
? {
id: ecosystemWallet.id,
partnerId: ecosystemWallet.getConfig()?.partnerId,
}
: undefined;

await unlinkProfile({
client,
ecosystem,
profileToUnlink,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,5 +131,30 @@ describe("LinkedProfilesScreen", () => {
render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.queryByText("Guest")).not.toBeInTheDocument();
});

it("should render unlink button when there are multiple profiles", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [
{ type: "email", details: { email: "[email protected]" } },
{ type: "google", details: { email: "[email protected]" } },
],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);

render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.getAllByLabelText("Unlink")).toHaveLength(2);
});

it("should not render unlink button when there is only one profile", () => {
vi.mocked(useProfiles).mockReturnValue({
data: [{ type: "email", details: { email: "[email protected]" } }],
isLoading: false,
// biome-ignore lint/suspicious/noExplicitAny: Mocking data
} as any);

render(<LinkedProfilesScreen {...mockProps} />);
expect(screen.queryByLabelText("Unlink")).not.toBeInTheDocument();
});
});
});
Loading
Loading