diff --git a/.changeset/strong-snails-jam.md b/.changeset/strong-snails-jam.md new file mode 100644 index 00000000000..29b562948e2 --- /dev/null +++ b/.changeset/strong-snails-jam.md @@ -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. +}); +``` diff --git a/packages/thirdweb/src/exports/react.native.ts b/packages/thirdweb/src/exports/react.native.ts index dc69361374a..fbba754f388 100644 --- a/packages/thirdweb/src/exports/react.native.ts +++ b/packages/thirdweb/src/exports/react.native.ts @@ -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"; diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index a3769f9316e..7f1cefba34c 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -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"; diff --git a/packages/thirdweb/src/exports/wallets.native.ts b/packages/thirdweb/src/exports/wallets.native.ts index 4938f486422..08ac10ad04c 100644 --- a/packages/thirdweb/src/exports/wallets.native.ts +++ b/packages/thirdweb/src/exports/wallets.native.ts @@ -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 = () => { diff --git a/packages/thirdweb/src/exports/wallets.ts b/packages/thirdweb/src/exports/wallets.ts index a7a365b0c4a..e9e24c3facc 100644 --- a/packages/thirdweb/src/exports/wallets.ts +++ b/packages/thirdweb/src/exports/wallets.ts @@ -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"; diff --git a/packages/thirdweb/src/exports/wallets/in-app.native.ts b/packages/thirdweb/src/exports/wallets/in-app.native.ts index 664342403cb..bf0515d84f5 100644 --- a/packages/thirdweb/src/exports/wallets/in-app.native.ts +++ b/packages/thirdweb/src/exports/wallets/in-app.native.ts @@ -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"; diff --git a/packages/thirdweb/src/exports/wallets/in-app.ts b/packages/thirdweb/src/exports/wallets/in-app.ts index c2a6cb2c1f5..e9e64cc6307 100644 --- a/packages/thirdweb/src/exports/wallets/in-app.ts +++ b/packages/thirdweb/src/exports/wallets/in-app.ts @@ -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"; diff --git a/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.test.tsx b/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.test.tsx new file mode 100644 index 00000000000..8cac647d74c --- /dev/null +++ b/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.test.tsx @@ -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 }) => ( + {children} + ); + + 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, + }); + }); +}); diff --git a/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.ts b/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.ts new file mode 100644 index 00000000000..2bbb443560d --- /dev/null +++ b/packages/thirdweb/src/react/native/hooks/wallets/useUnlinkProfile.ts @@ -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"] }); + }, + }); +} diff --git a/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.test.tsx b/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.test.tsx new file mode 100644 index 00000000000..8cac647d74c --- /dev/null +++ b/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.test.tsx @@ -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 }) => ( + {children} + ); + + 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, + }); + }); +}); diff --git a/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.ts b/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.ts new file mode 100644 index 00000000000..2bbb443560d --- /dev/null +++ b/packages/thirdweb/src/react/web/hooks/wallets/useUnlinkProfile.ts @@ -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"] }); + }, + }); +} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx index de2b9e8e9b1..2967c7c69c0 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.test.tsx @@ -131,5 +131,30 @@ describe("LinkedProfilesScreen", () => { render(); 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: "test@example.com" } }, + { type: "google", details: { email: "google@example.com" } }, + ], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + 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: "test@example.com" } }], + isLoading: false, + // biome-ignore lint/suspicious/noExplicitAny: Mocking data + } as any); + + render(); + expect(screen.queryByLabelText("Unlink")).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx index c9b3128edee..aba5ef8ec9e 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/LinkedProfilesScreen.tsx @@ -1,5 +1,7 @@ "use client"; +import { Cross2Icon } from "@radix-ui/react-icons"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { useUnlinkProfile } from "../../../../../react/web/hooks/wallets/useUnlinkProfile.js"; import { shortenAddress } from "../../../../../utils/address.js"; import type { Profile } from "../../../../../wallets/in-app/core/authentication/types.js"; import { fontSize, iconSize } from "../../../../core/design-system/index.js"; @@ -10,6 +12,7 @@ import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js"; import { Img } from "../../components/Img.js"; import { Spacer } from "../../components/Spacer.js"; import { Container, Line, ModalHeader } from "../../components/basic.js"; +import { IconButton } from "../../components/buttons.js"; import { Text } from "../../components/text.js"; import { Blobbie } from "../Blobbie.js"; import { MenuButton } from "../MenuButton.js"; @@ -70,57 +73,61 @@ export function LinkedProfilesScreen(props: { /> - {isLoading ? ( - - ) : ( - - - - { - props.setScreen("link-profile"); - }} - style={{ - fontSize: fontSize.sm, - }} - > - - - {props.locale.manageWallet.linkProfile} - - - - {/* Exclude guest as a profile */} - {connectedProfiles - ?.filter((profile) => profile.type !== "guest") - .map((profile) => ( - - ))} - - + + + + + { + props.setScreen("link-profile"); + }} + style={{ + fontSize: fontSize.sm, + }} + > + + + {props.locale.manageWallet.linkProfile} + + + + {/* Exclude guest as a profile */} + {connectedProfiles + ?.filter((profile) => profile.type !== "guest") + .map((profile) => ( + 1} + profile={profile} + client={props.client} + /> + ))} - )} + + ); } function LinkedProfile({ profile, + enableUnlinking, client, -}: { profile: Profile; client: ThirdwebClient }) { +}: { + profile: Profile; + enableUnlinking: boolean; + client: ThirdwebClient; +}) { const { data: socialProfiles } = useSocialProfiles({ client, address: profile.details.address, }); + const { mutate: unlinkProfileMutation, isPending } = useUnlinkProfile(); return ( {socialProfiles?.some((p) => p.avatar) ? ( @@ -180,12 +188,46 @@ function LinkedProfile({ {socialProfiles?.find((p) => p.avatar)?.name || getProfileDisplayName(profile)} - {socialProfiles?.find((p) => p.avatar)?.name && - profile.details.address && ( - - {shortenAddress(profile.details.address, 4)} - +
+ {socialProfiles?.find((p) => p.avatar)?.name && + profile.details.address && ( + + {shortenAddress(profile.details.address, 4)} + + )} + {enableUnlinking && ( + + unlinkProfileMutation({ + client, + profileToUnlink: profile, + }) + } + style={{ + pointerEvents: "auto", + }} + disabled={isPending} + > + + )} +
); diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.test.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.test.ts new file mode 100644 index 00000000000..9703631e19e --- /dev/null +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createThirdwebClient } from "../../../../client/client.js"; +import { getClientFetch } from "../../../../utils/fetch.js"; +import type { ClientScopedStorage } from "./client-scoped-storage.js"; +import { + getLinkedProfilesInternal, + linkAccount, + unlinkAccount, +} from "./linkAccount.js"; +import type { Profile } from "./types.js"; + +vi.mock("../../../../utils/fetch.js"); + +describe("Account linking functions", () => { + const mockClient = createThirdwebClient({ clientId: "mock-client-id" }); + const mockStorage = { + getAuthCookie: vi.fn(), + } as unknown as ClientScopedStorage; + const mockFetch = vi.fn(); + const mockLinkedAccounts = [ + { type: "email", details: { email: "user@example.com" } }, + { type: "phone", details: { phone: "1234567890" } }, + { type: "wallet", details: { address: "0x123456789" } }, + ] satisfies Profile[]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getClientFetch).mockReturnValue(mockFetch); + vi.mocked(mockStorage.getAuthCookie).mockResolvedValue("mock-token"); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ linkedAccounts: mockLinkedAccounts }), + }); + }); + + describe("linkAccount", () => { + it("should successfully link an account", async () => { + const result = await linkAccount({ + client: mockClient, + tokenToLink: "token-to-link", + storage: mockStorage, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://embedded-wallet.thirdweb.com/api/2024-05-05/account/connect", + { + method: "POST", + headers: { + Authorization: "Bearer iaw-auth-token:mock-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + accountAuthTokenToConnect: "token-to-link", + }), + }, + ); + expect(result).toEqual(mockLinkedAccounts); + }); + + it("should throw error when no user is logged in", async () => { + vi.mocked(mockStorage.getAuthCookie).mockResolvedValue(null); + + await expect( + linkAccount({ + client: mockClient, + tokenToLink: "token-to-link", + storage: mockStorage, + }), + ).rejects.toThrow("Failed to link account, no user logged in"); + }); + }); + + describe("unlinkAccount", () => { + const profileToUnlink = { + type: "email", + details: { email: "user@example.com" }, + } satisfies Profile; + it("should successfully unlink an account", async () => { + const result = await unlinkAccount({ + client: mockClient, + profileToUnlink, + storage: mockStorage, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://embedded-wallet.thirdweb.com/api/2024-05-05/account/disconnect", + { + method: "POST", + headers: { + Authorization: "Bearer iaw-auth-token:mock-token", + "Content-Type": "application/json", + }, + body: JSON.stringify(profileToUnlink), + }, + ); + expect(result).toEqual(mockLinkedAccounts); + }); + + it("should throw error when no user is logged in", async () => { + vi.mocked(mockStorage.getAuthCookie).mockResolvedValue(null); + + await expect( + unlinkAccount({ + client: mockClient, + profileToUnlink, + storage: mockStorage, + }), + ).rejects.toThrow("Failed to unlink account, no user logged in"); + }); + it("should handle API errors", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ message: "API Error" }), + }); + + await expect( + unlinkAccount({ + client: mockClient, + profileToUnlink, + storage: mockStorage, + }), + ).rejects.toThrow("API Error"); + }); + }); + + describe("getLinkedProfilesInternal", () => { + it("should successfully get linked profiles", async () => { + const result = await getLinkedProfilesInternal({ + client: mockClient, + storage: mockStorage, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://embedded-wallet.thirdweb.com/api/2024-05-05/accounts", + { + method: "GET", + headers: { + Authorization: "Bearer iaw-auth-token:mock-token", + "Content-Type": "application/json", + }, + }, + ); + expect(result).toEqual(mockLinkedAccounts); + }); + + it("should handle API errors", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ message: "API Error" }), + }); + + await expect( + getLinkedProfilesInternal({ + client: mockClient, + storage: mockStorage, + }), + ).rejects.toThrow("API Error"); + }); + }); +}); diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.ts index 81508260383..b5dbcdbe4d6 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.ts @@ -57,6 +57,55 @@ export async function linkAccount({ return (linkedAccounts ?? []) satisfies Profile[]; } +/** + * @description + * Links a new account to the current one using an auth token. + * For the public-facing API, use `wallet.linkProfile` instead. + * + * @internal + */ +export async function unlinkAccount({ + client, + ecosystem, + profileToUnlink, + storage, +}: { + client: ThirdwebClient; + ecosystem?: Ecosystem; + profileToUnlink: Profile; + storage: ClientScopedStorage; +}): Promise { + const clientFetch = getClientFetch(client, ecosystem); + const IN_APP_URL = getThirdwebBaseUrl("inAppWallet"); + const currentAccountToken = await storage.getAuthCookie(); + + if (!currentAccountToken) { + throw new Error("Failed to unlink account, no user logged in"); + } + + const headers: Record = { + Authorization: `Bearer iaw-auth-token:${currentAccountToken}`, + "Content-Type": "application/json", + }; + const linkedDetailsResp = await clientFetch( + `${IN_APP_URL}/api/2024-05-05/account/disconnect`, + { + method: "POST", + headers, + body: stringify(profileToUnlink), + }, + ); + + if (!linkedDetailsResp.ok) { + const body = await linkedDetailsResp.json(); + throw new Error(body.message || "Failed to unlink account."); + } + + const { linkedAccounts } = await linkedDetailsResp.json(); + + return (linkedAccounts ?? []) satisfies Profile[]; +} + /** * @description * Gets the linked accounts for the current user. diff --git a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts index 66af171ee87..f9c6bb5c037 100644 --- a/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts +++ b/packages/thirdweb/src/wallets/in-app/core/authentication/types.ts @@ -250,3 +250,9 @@ export type GetAuthenticatedUserParams = { client: ThirdwebClient; ecosystem?: Ecosystem; }; + +export type UnlinkParams = { + client: ThirdwebClient; + ecosystem?: Ecosystem; + profileToUnlink: Profile; +}; diff --git a/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts b/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts index 6c0ca3a7908..7c2f5170d4f 100644 --- a/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts +++ b/packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts @@ -36,5 +36,6 @@ export interface InAppConnector { ): Promise; logout(): Promise; linkProfile(args: AuthArgsType): Promise; + unlinkProfile(args: Profile): Promise; getProfiles(): Promise; } diff --git a/packages/thirdweb/src/wallets/in-app/native/auth/index.ts b/packages/thirdweb/src/wallets/in-app/native/auth/index.ts index 4fcc2f13134..18574692df5 100644 --- a/packages/thirdweb/src/wallets/in-app/native/auth/index.ts +++ b/packages/thirdweb/src/wallets/in-app/native/auth/index.ts @@ -3,6 +3,7 @@ import type { AuthArgsType, GetAuthenticatedUserParams, PreAuthArgsType, + UnlinkParams, } from "../../core/authentication/types.js"; import { getOrCreateInAppWalletConnector } from "../../core/wallet/in-app-core.js"; import type { Ecosystem } from "../../core/wallet/types.js"; @@ -146,9 +147,6 @@ export async function authenticate(args: AuthArgsType) { * * **When a profile is linked to the account, that profile can then be used to sign into the account.** * - * This method is only available for in-app wallets. - * - * @param wallet - The wallet to link an additional profile to. * @param auth - The authentications options to add the new profile. * @returns A promise that resolves to the currently linked profiles when the connection is successful. * @throws If the connection fails, if the profile is already linked to the account, or if the profile is already associated with another account. @@ -167,6 +165,36 @@ export async function linkProfile(args: AuthArgsType) { return await connector.linkProfile(args); } +/** + * Disconnects an existing profile (authentication method) from the current user. Once disconnected, that profile can no longer be used to sign into the account. + * + * @param args - The object containing the profile that we want to unlink. + * @returns A promise that resolves to the updated linked profiles. + * @throws If the unlinking fails. This can happen if the account has no other associated profiles or if the profile that is being unlinked doesn't exists for the current logged in user. + * + * @example + * ```ts + * import { inAppWallet } from "thirdweb/wallets"; + * + * const wallet = inAppWallet(); + * wallet.connect({ strategy: "google" }); + * + * const profiles = await getProfiles({ + * client, + * }); + * + * const updatedProfiles = await unlinkProfile({ + * client, + * profileToUnlink: profiles[0], + * }); + * ``` + * @wallet + */ +export async function unlinkProfile(args: UnlinkParams) { + const connector = await getInAppWalletConnector(args.client, args.ecosystem); + return await connector.unlinkProfile(args.profileToUnlink); +} + /** * Gets the linked profiles for the connected in-app or ecosystem wallet. * diff --git a/packages/thirdweb/src/wallets/in-app/native/native-connector.ts b/packages/thirdweb/src/wallets/in-app/native/native-connector.ts index c9ba843949c..9e0e4c61167 100644 --- a/packages/thirdweb/src/wallets/in-app/native/native-connector.ts +++ b/packages/thirdweb/src/wallets/in-app/native/native-connector.ts @@ -10,6 +10,7 @@ import { customJwt } from "../core/authentication/jwt.js"; import { getLinkedProfilesInternal, linkAccount, + unlinkAccount, } from "../core/authentication/linkAccount.js"; import { loginWithPasskey, @@ -24,6 +25,7 @@ import type { LogoutReturnType, MultiStepAuthArgsType, MultiStepAuthProviderType, + Profile, SingleStepAuthArgsType, } from "../core/authentication/types.js"; import type { InAppConnector } from "../core/interfaces/connector.js"; @@ -332,6 +334,15 @@ export class InAppNativeConnector implements InAppConnector { }); } + async unlinkProfile(profile: Profile) { + return await unlinkAccount({ + client: this.client, + ecosystem: this.ecosystem, + storage: this.storage, + profileToUnlink: profile, + }); + } + async getProfiles() { return getLinkedProfilesInternal({ client: this.client, diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts b/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts index 84886844cbf..7898597e45e 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts @@ -5,6 +5,7 @@ import type { GetAuthenticatedUserParams, PreAuthArgsType, SocialAuthArgsType, + UnlinkParams, } from "../../../core/authentication/types.js"; import { getOrCreateInAppWalletConnector } from "../../../core/wallet/in-app-core.js"; import type { Ecosystem } from "../../../core/wallet/types.js"; @@ -204,6 +205,36 @@ export async function linkProfile(args: AuthArgsType) { return await connector.linkProfile(args); } +/** + * Disconnects an existing profile (authentication method) from the current user. Once disconnected, that profile can no longer be used to sign into the account. + * + * @param args - The object containing the profile that we want to unlink. + * @returns A promise that resolves to the updated linked profiles. + * @throws If the unlinking fails. This can happen if the account has no other associated profiles or if the profile that is being unlinked doesn't exists for the current logged in user. + * + * @example + * ```ts + * import { inAppWallet } from "thirdweb/wallets"; + * + * const wallet = inAppWallet(); + * wallet.connect({ strategy: "google" }); + * + * const profiles = await getProfiles({ + * client, + * }); + * + * const updatedProfiles = await unlinkProfile({ + * client, + * profileToUnlink: profiles[0], + * }); + * ``` + * @wallet + */ +export async function unlinkProfile(args: UnlinkParams) { + const connector = await getInAppWalletConnector(args.client, args.ecosystem); + return await connector.unlinkProfile(args.profileToUnlink); +} + /** * Gets the linked profiles for the connected in-app or ecosystem wallet. * diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts index e6c990dc648..c1a1d6afd56 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts @@ -11,6 +11,7 @@ import { customJwt } from "../../core/authentication/jwt.js"; import { getLinkedProfilesInternal, linkAccount, + unlinkAccount, } from "../../core/authentication/linkAccount.js"; import { loginWithPasskey, @@ -25,6 +26,7 @@ import type { LogoutReturnType, MultiStepAuthArgsType, MultiStepAuthProviderType, + Profile, SingleStepAuthArgsType, } from "../../core/authentication/types.js"; import type { InAppConnector } from "../../core/interfaces/connector.js"; @@ -456,6 +458,15 @@ export class InAppWebConnector implements InAppConnector { }); } + async unlinkProfile(profile: Profile) { + return await unlinkAccount({ + client: this.client, + storage: this.storage, + ecosystem: this.ecosystem, + profileToUnlink: profile, + }); + } + async getProfiles() { return getLinkedProfilesInternal({ client: this.client,