Skip to content

Commit 53d2fe4

Browse files
committed
feat: add unlinking for in app wallet accounts
1 parent c3d7b66 commit 53d2fe4

File tree

9 files changed

+216
-10
lines changed

9 files changed

+216
-10
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,12 +908,22 @@ function DetailsModal(props: {
908908
/>
909909
);
910910
} else if (screen === "linked-profiles") {
911+
let ecosystem: Ecosystem | undefined;
912+
if (activeWallet && isEcosystemWallet(activeWallet)) {
913+
const ecosystemWallet = activeWallet as Wallet<EcosystemWalletId>;
914+
const partnerId = ecosystemWallet.getConfig()?.partnerId;
915+
ecosystem = {
916+
id: ecosystemWallet.id,
917+
partnerId,
918+
};
919+
}
911920
content = (
912921
<LinkedProfilesScreen
913922
onBack={() => setScreen("manage-wallet")}
914923
client={client}
915924
locale={locale}
916925
setScreen={setScreen}
926+
ecosystem={ecosystem}
917927
/>
918928
);
919929
} else if (screen === "link-profile") {

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

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"use client";
2+
import { Cross2Icon } from "@radix-ui/react-icons";
3+
import { useMutation, useQueryClient } from "@tanstack/react-query";
24
import type { ThirdwebClient } from "../../../../../client/client.js";
35
import { shortenAddress } from "../../../../../utils/address.js";
46
import type { Profile } from "../../../../../wallets/in-app/core/authentication/types.js";
7+
import type { Ecosystem } from "../../../../../wallets/in-app/core/wallet/types.js";
8+
import { unlinkProfile } from "../../../../../wallets/in-app/web/lib/auth/index.js";
59
import { fontSize, iconSize } from "../../../../core/design-system/index.js";
610
import { useSocialProfiles } from "../../../../core/social/useSocialProfiles.js";
711
import { getSocialIcon } from "../../../../core/utils/walletIcon.js";
@@ -10,6 +14,7 @@ import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js";
1014
import { Img } from "../../components/Img.js";
1115
import { Spacer } from "../../components/Spacer.js";
1216
import { Container, Line, ModalHeader } from "../../components/basic.js";
17+
import { IconButton } from "../../components/buttons.js";
1318
import { Text } from "../../components/text.js";
1419
import { Blobbie } from "../Blobbie.js";
1520
import { MenuButton } from "../MenuButton.js";
@@ -46,6 +51,7 @@ export function LinkedProfilesScreen(props: {
4651
setScreen: (screen: WalletDetailsModalScreen) => void;
4752
locale: ConnectLocale;
4853
client: ThirdwebClient;
54+
ecosystem?: Ecosystem;
4955
}) {
5056
const { data: connectedProfiles, isLoading } = useProfiles({
5157
client: props.client,
@@ -99,6 +105,7 @@ export function LinkedProfilesScreen(props: {
99105
.map((profile) => (
100106
<LinkedProfile
101107
key={`${profile.type}-${getProfileDisplayName(profile)}`}
108+
enableUnlinking={connectedProfiles.length > 1}
102109
profile={profile}
103110
client={props.client}
104111
/>
@@ -113,11 +120,33 @@ export function LinkedProfilesScreen(props: {
113120

114121
function LinkedProfile({
115122
profile,
123+
enableUnlinking,
116124
client,
117-
}: { profile: Profile; client: ThirdwebClient }) {
125+
ecosystem,
126+
}: {
127+
profile: Profile;
128+
enableUnlinking: boolean;
129+
client: ThirdwebClient;
130+
ecosystem?: Ecosystem;
131+
}) {
118132
const { data: socialProfiles } = useSocialProfiles({
119133
client,
120-
address: profile.details.address,
134+
address: profile.details.address
135+
? "0x225f137127d9067788314bc7fcc1f36746a3c3B5"
136+
: undefined,
137+
});
138+
const queryClient = useQueryClient();
139+
const { mutate: unlinkProfileMutation, isPending } = useMutation({
140+
mutationFn: async () => {
141+
await unlinkProfile({
142+
client,
143+
ecosystem,
144+
profileToUnlink: profile,
145+
});
146+
},
147+
onSuccess: () => {
148+
queryClient.invalidateQueries({ queryKey: ["profiles"] });
149+
},
121150
});
122151

123152
return (
@@ -126,6 +155,7 @@ function LinkedProfile({
126155
fontSize: fontSize.sm,
127156
cursor: "default",
128157
}}
158+
as={"div"}
129159
disabled // disabled until we have more data to show on a dedicated profile screen
130160
>
131161
{socialProfiles?.some((p) => p.avatar) ? (
@@ -178,12 +208,41 @@ function LinkedProfile({
178208
{socialProfiles?.find((p) => p.avatar)?.name ||
179209
getProfileDisplayName(profile)}
180210
</Text>
181-
{socialProfiles?.find((p) => p.avatar)?.name &&
182-
profile.details.address && (
183-
<Text color="secondaryText" size="sm">
184-
{shortenAddress(profile.details.address, 4)}
185-
</Text>
211+
<div
212+
style={{
213+
display: "flex",
214+
flexDirection: "row",
215+
alignItems: "center",
216+
gap: "8px",
217+
}}
218+
>
219+
{socialProfiles?.find((p) => p.avatar)?.name &&
220+
profile.details.address && (
221+
<Text color="secondaryText" size="sm">
222+
{shortenAddress(profile.details.address, 4)}
223+
</Text>
224+
)}
225+
{enableUnlinking && (
226+
<IconButton
227+
autoFocus
228+
type="button"
229+
aria-label="Close"
230+
onClick={() => unlinkProfileMutation()}
231+
style={{
232+
pointerEvents: "auto",
233+
}}
234+
disabled={isPending}
235+
>
236+
<Cross2Icon
237+
width={iconSize.md}
238+
height={iconSize.md}
239+
style={{
240+
color: "inherit",
241+
}}
242+
/>
243+
</IconButton>
186244
)}
245+
</div>
187246
</div>
188247
</MenuButton>
189248
);

packages/thirdweb/src/wallets/in-app/core/authentication/linkAccount.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,55 @@ export async function linkAccount({
5757
return (linkedAccounts ?? []) satisfies Profile[];
5858
}
5959

60+
/**
61+
* @description
62+
* Links a new account to the current one using an auth token.
63+
* For the public-facing API, use `wallet.linkProfile` instead.
64+
*
65+
* @internal
66+
*/
67+
export async function unlinkAccount({
68+
client,
69+
ecosystem,
70+
profileToUnlink,
71+
storage,
72+
}: {
73+
client: ThirdwebClient;
74+
ecosystem?: Ecosystem;
75+
profileToUnlink: Profile;
76+
storage: ClientScopedStorage;
77+
}): Promise<Profile[]> {
78+
const clientFetch = getClientFetch(client, ecosystem);
79+
const IN_APP_URL = getThirdwebBaseUrl("inAppWallet");
80+
const currentAccountToken = await storage.getAuthCookie();
81+
82+
if (!currentAccountToken) {
83+
throw new Error("Failed to unlink account, no user logged in");
84+
}
85+
86+
const headers: Record<string, string> = {
87+
Authorization: `Bearer iaw-auth-token:${currentAccountToken}`,
88+
"Content-Type": "application/json",
89+
};
90+
const linkedDetailsResp = await clientFetch(
91+
`${IN_APP_URL}/api/2024-05-05/account/disconnect`,
92+
{
93+
method: "POST",
94+
headers,
95+
body: stringify(profileToUnlink),
96+
},
97+
);
98+
99+
if (!linkedDetailsResp.ok) {
100+
const body = await linkedDetailsResp.json();
101+
throw new Error(body.message || "Failed to unlink account.");
102+
}
103+
104+
const { linkedAccounts } = await linkedDetailsResp.json();
105+
106+
return (linkedAccounts ?? []) satisfies Profile[];
107+
}
108+
60109
/**
61110
* @description
62111
* Gets the linked accounts for the current user.

packages/thirdweb/src/wallets/in-app/core/authentication/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,9 @@ export type GetAuthenticatedUserParams = {
250250
client: ThirdwebClient;
251251
ecosystem?: Ecosystem;
252252
};
253+
254+
export type UnlinkParams = {
255+
client: ThirdwebClient;
256+
ecosystem?: Ecosystem;
257+
profileToUnlink: Profile;
258+
};

packages/thirdweb/src/wallets/in-app/core/interfaces/connector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ export interface InAppConnector {
3636
): Promise<AuthLoginReturnType>;
3737
logout(): Promise<LogoutReturnType>;
3838
linkProfile(args: AuthArgsType): Promise<Profile[]>;
39+
unlinkProfile(args: Profile): Promise<Profile[]>;
3940
getProfiles(): Promise<Profile[]>;
4041
}

packages/thirdweb/src/wallets/in-app/native/auth/index.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
AuthArgsType,
44
GetAuthenticatedUserParams,
55
PreAuthArgsType,
6+
UnlinkParams,
67
} from "../../core/authentication/types.js";
78
import { getOrCreateInAppWalletConnector } from "../../core/wallet/in-app-core.js";
89
import type { Ecosystem } from "../../core/wallet/types.js";
@@ -146,9 +147,6 @@ export async function authenticate(args: AuthArgsType) {
146147
*
147148
* **When a profile is linked to the account, that profile can then be used to sign into the account.**
148149
*
149-
* This method is only available for in-app wallets.
150-
*
151-
* @param wallet - The wallet to link an additional profile to.
152150
* @param auth - The authentications options to add the new profile.
153151
* @returns A promise that resolves to the currently linked profiles when the connection is successful.
154152
* @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) {
167165
return await connector.linkProfile(args);
168166
}
169167

168+
/**
169+
* Disconnects an existing profile (authentication method) from the current user. Once disconnected, that profile can no longer be used to sign into the account.
170+
*
171+
* @param args - The object containing the profile that we want to unlink.
172+
* @returns A promise that resolves to the updated linked profiles.
173+
* @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.
174+
*
175+
* @example
176+
* ```ts
177+
* import { inAppWallet } from "thirdweb/wallets";
178+
*
179+
* const wallet = inAppWallet();
180+
* wallet.connect({ strategy: "google" });
181+
*
182+
* const profiles = await getProfiles({
183+
* client,
184+
* });
185+
*
186+
* const updatedProfiles = await unlinkProfile({
187+
* client,
188+
* profileToUnlink: profiles[0],
189+
* });
190+
* ```
191+
* @wallet
192+
*/
193+
export async function unlinkProfile(args: UnlinkParams) {
194+
const connector = await getInAppWalletConnector(args.client, args.ecosystem);
195+
return await connector.unlinkProfile(args.profileToUnlink);
196+
}
197+
170198
/**
171199
* Gets the linked profiles for the connected in-app or ecosystem wallet.
172200
*

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { customJwt } from "../core/authentication/jwt.js";
1010
import {
1111
getLinkedProfilesInternal,
1212
linkAccount,
13+
unlinkAccount,
1314
} from "../core/authentication/linkAccount.js";
1415
import {
1516
loginWithPasskey,
@@ -24,6 +25,7 @@ import type {
2425
LogoutReturnType,
2526
MultiStepAuthArgsType,
2627
MultiStepAuthProviderType,
28+
Profile,
2729
SingleStepAuthArgsType,
2830
} from "../core/authentication/types.js";
2931
import type { InAppConnector } from "../core/interfaces/connector.js";
@@ -332,6 +334,15 @@ export class InAppNativeConnector implements InAppConnector {
332334
});
333335
}
334336

337+
async unlinkProfile(profile: Profile) {
338+
return await unlinkAccount({
339+
client: this.client,
340+
ecosystem: this.ecosystem,
341+
storage: this.storage,
342+
profileToUnlink: profile,
343+
});
344+
}
345+
335346
async getProfiles() {
336347
return getLinkedProfilesInternal({
337348
client: this.client,

packages/thirdweb/src/wallets/in-app/web/lib/auth/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
GetAuthenticatedUserParams,
66
PreAuthArgsType,
77
SocialAuthArgsType,
8+
UnlinkParams,
89
} from "../../../core/authentication/types.js";
910
import { getOrCreateInAppWalletConnector } from "../../../core/wallet/in-app-core.js";
1011
import type { Ecosystem } from "../../../core/wallet/types.js";
@@ -204,6 +205,36 @@ export async function linkProfile(args: AuthArgsType) {
204205
return await connector.linkProfile(args);
205206
}
206207

208+
/**
209+
* Disconnects an existing profile (authentication method) from the current user. Once disconnected, that profile can no longer be used to sign into the account.
210+
*
211+
* @param args - The object containing the profile that we want to unlink.
212+
* @returns A promise that resolves to the updated linked profiles.
213+
* @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.
214+
*
215+
* @example
216+
* ```ts
217+
* import { inAppWallet } from "thirdweb/wallets";
218+
*
219+
* const wallet = inAppWallet();
220+
* wallet.connect({ strategy: "google" });
221+
*
222+
* const profiles = await getProfiles({
223+
* client,
224+
* });
225+
*
226+
* const updatedProfiles = await unlinkProfile({
227+
* client,
228+
* profileToUnlink: profiles[0],
229+
* });
230+
* ```
231+
* @wallet
232+
*/
233+
export async function unlinkProfile(args: UnlinkParams) {
234+
const connector = await getInAppWalletConnector(args.client, args.ecosystem);
235+
return await connector.unlinkProfile(args.profileToUnlink);
236+
}
237+
207238
/**
208239
* Gets the linked profiles for the connected in-app or ecosystem wallet.
209240
*

packages/thirdweb/src/wallets/in-app/web/lib/web-connector.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { customJwt } from "../../core/authentication/jwt.js";
1111
import {
1212
getLinkedProfilesInternal,
1313
linkAccount,
14+
unlinkAccount,
1415
} from "../../core/authentication/linkAccount.js";
1516
import {
1617
loginWithPasskey,
@@ -25,6 +26,7 @@ import type {
2526
LogoutReturnType,
2627
MultiStepAuthArgsType,
2728
MultiStepAuthProviderType,
29+
Profile,
2830
SingleStepAuthArgsType,
2931
} from "../../core/authentication/types.js";
3032
import type { InAppConnector } from "../../core/interfaces/connector.js";
@@ -456,6 +458,15 @@ export class InAppWebConnector implements InAppConnector {
456458
});
457459
}
458460

461+
async unlinkProfile(profile: Profile) {
462+
return await unlinkAccount({
463+
client: this.client,
464+
storage: this.storage,
465+
ecosystem: this.ecosystem,
466+
profileToUnlink: profile,
467+
});
468+
}
469+
459470
async getProfiles() {
460471
return getLinkedProfilesInternal({
461472
client: this.client,

0 commit comments

Comments
 (0)