Skip to content

Commit cf0cdf4

Browse files
0xFirekeeperclaude
andauthored
[Dashboard] Add revoke sessions to user wallets configuration (#8996)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a834fe1 commit cf0cdf4

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"use server";
2+
import "server-only";
3+
4+
import { getAuthToken } from "@/api/auth-token";
5+
import { THIRDWEB_INAPP_WALLET_DOMAIN } from "@/constants/urls";
6+
7+
export type RevokeSessionsTarget =
8+
| { type: "email" | "phone" | "walletAddress" | "userId"; value: string }
9+
| { type: "allUsers" };
10+
11+
type RevokeSessionsResult =
12+
| {
13+
success: true;
14+
scope: "user" | "project";
15+
userCount: number;
16+
tokensInvalidBefore: string;
17+
}
18+
| { success: false; error: string };
19+
20+
export async function revokeUserWalletSessions(params: {
21+
teamId: string;
22+
clientId: string;
23+
secretKey: string;
24+
target: RevokeSessionsTarget;
25+
}): Promise<RevokeSessionsResult> {
26+
const token = await getAuthToken();
27+
if (!token) {
28+
return { error: "Unauthorized", success: false };
29+
}
30+
31+
const secretKey = params.secretKey.trim();
32+
const { target } = params;
33+
if (!secretKey || (target.type !== "allUsers" && !target.value.trim())) {
34+
return { error: "Missing required fields", success: false };
35+
}
36+
37+
const protocol = THIRDWEB_INAPP_WALLET_DOMAIN.startsWith("localhost")
38+
? "http"
39+
: "https";
40+
41+
let res: Response;
42+
try {
43+
res = await fetch(
44+
`${protocol}://${THIRDWEB_INAPP_WALLET_DOMAIN}/api/v1/users/revoke-sessions`,
45+
{
46+
body: JSON.stringify({
47+
clientId: params.clientId,
48+
secretKey,
49+
...(target.type === "allUsers"
50+
? { allUsers: true }
51+
: { [target.type]: target.value.trim() }),
52+
}),
53+
cache: "no-store",
54+
headers: {
55+
Authorization: `Bearer ${token}`,
56+
"Content-Type": "application/json",
57+
"x-client-id": params.clientId,
58+
"x-thirdweb-team-id": params.teamId,
59+
},
60+
method: "POST",
61+
},
62+
);
63+
} catch {
64+
return { error: "Failed to reach the wallet service", success: false };
65+
}
66+
67+
const json = (await res.json().catch(() => null)) as {
68+
message?: string;
69+
scope?: "user" | "project";
70+
userIds?: string[];
71+
tokensInvalidBefore?: string;
72+
} | null;
73+
74+
if (!res.ok || !json?.scope || !json.tokensInvalidBefore) {
75+
return {
76+
error: json?.message || `Request failed with status ${res.status}`,
77+
success: false,
78+
};
79+
}
80+
81+
return {
82+
scope: json.scope,
83+
success: true,
84+
tokensInvalidBefore: json.tokensInvalidBefore,
85+
userCount: json.userIds?.length ?? 0,
86+
};
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"use client";
2+
import { useMutation } from "@tanstack/react-query";
3+
import { useState } from "react";
4+
import { toast } from "sonner";
5+
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
6+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
7+
import { Input } from "@/components/ui/input";
8+
import { Label } from "@/components/ui/label";
9+
import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group";
10+
import {
11+
Select,
12+
SelectContent,
13+
SelectItem,
14+
SelectTrigger,
15+
SelectValue,
16+
} from "@/components/ui/select";
17+
import {
18+
type RevokeSessionsTarget,
19+
revokeUserWalletSessions,
20+
} from "../api/revoke-sessions";
21+
22+
type IdentifierType = "email" | "phone" | "walletAddress" | "userId";
23+
24+
const identifierOptions: {
25+
value: IdentifierType;
26+
label: string;
27+
placeholder: string;
28+
}[] = [
29+
{ label: "Email", placeholder: "user@example.com", value: "email" },
30+
{ label: "Phone", placeholder: "+15555555555", value: "phone" },
31+
{ label: "Wallet address", placeholder: "0x...", value: "walletAddress" },
32+
{ label: "User ID", placeholder: "User ID", value: "userId" },
33+
];
34+
35+
export function RevokeSessionsCard(props: {
36+
clientId: string;
37+
teamId: string;
38+
}) {
39+
const [scope, setScope] = useState<"user" | "all">("user");
40+
const [identifierType, setIdentifierType] = useState<IdentifierType>("email");
41+
const [identifier, setIdentifier] = useState("");
42+
const [secretKey, setSecretKey] = useState("");
43+
44+
const selectedOption = identifierOptions.find(
45+
(option) => option.value === identifierType,
46+
);
47+
const isReady =
48+
secretKey.trim().length > 0 &&
49+
(scope === "all" || identifier.trim().length > 0);
50+
51+
const revokeSessions = useMutation({
52+
mutationFn: async () => {
53+
const target: RevokeSessionsTarget =
54+
scope === "all"
55+
? { type: "allUsers" }
56+
: { type: identifierType, value: identifier };
57+
const result = await revokeUserWalletSessions({
58+
clientId: props.clientId,
59+
secretKey,
60+
target,
61+
teamId: props.teamId,
62+
});
63+
if (!result.success) {
64+
throw new Error(result.error);
65+
}
66+
return result;
67+
},
68+
onError: (error) => {
69+
toast.error(error.message || "Failed to revoke sessions");
70+
},
71+
onSuccess: () => {
72+
setSecretKey("");
73+
toast.success("Sessions revoked");
74+
},
75+
});
76+
77+
return (
78+
<DangerSettingCard
79+
buttonLabel="Revoke sessions"
80+
buttonOnClick={() => {
81+
if (isReady) {
82+
revokeSessions.mutate();
83+
}
84+
}}
85+
confirmationDialog={{
86+
children: revokeSessions.data ? (
87+
<Alert className="mt-4" variant="info">
88+
<AlertTitle>Sessions revoked</AlertTitle>
89+
<AlertDescription>
90+
{revokeSessions.data.scope === "project"
91+
? "All users"
92+
: `${revokeSessions.data.userCount} user${revokeSessions.data.userCount === 1 ? "" : "s"}`}{" "}
93+
signed out as of{" "}
94+
{new Date(
95+
revokeSessions.data.tokensInvalidBefore,
96+
).toLocaleString()}
97+
.
98+
</AlertDescription>
99+
</Alert>
100+
) : null,
101+
description:
102+
scope === "all"
103+
? "Every user of this project will be signed out of all devices."
104+
: `${selectedOption?.label}: ${identifier.trim()}`,
105+
onClose: () => revokeSessions.reset(),
106+
title:
107+
scope === "all"
108+
? "Revoke sessions for all users?"
109+
: "Revoke sessions for this user?",
110+
}}
111+
description="Sign users out of every device. Existing sessions stop working immediately and users can sign in again."
112+
isDisabled={!isReady}
113+
isPending={revokeSessions.isPending}
114+
title="Revoke sessions"
115+
>
116+
<div className="space-y-5">
117+
<RadioGroup
118+
className="flex flex-col gap-3 lg:flex-row"
119+
onValueChange={(value) => setScope(value as "user" | "all")}
120+
value={scope}
121+
>
122+
<RadioGroupItemButton id="revoke-scope-user" value="user">
123+
A single user
124+
</RadioGroupItemButton>
125+
<RadioGroupItemButton id="revoke-scope-all" value="all">
126+
All users
127+
</RadioGroupItemButton>
128+
</RadioGroup>
129+
130+
{scope === "user" && (
131+
<div className="flex flex-col gap-3 md:flex-row">
132+
<Select
133+
onValueChange={(value) =>
134+
setIdentifierType(value as IdentifierType)
135+
}
136+
value={identifierType}
137+
>
138+
<SelectTrigger className="bg-card md:w-48">
139+
<SelectValue />
140+
</SelectTrigger>
141+
<SelectContent>
142+
{identifierOptions.map((option) => (
143+
<SelectItem key={option.value} value={option.value}>
144+
{option.label}
145+
</SelectItem>
146+
))}
147+
</SelectContent>
148+
</Select>
149+
<Input
150+
aria-label={selectedOption?.label}
151+
className="bg-card"
152+
onChange={(e) => setIdentifier(e.target.value)}
153+
placeholder={selectedOption?.placeholder}
154+
value={identifier}
155+
/>
156+
</div>
157+
)}
158+
159+
<div className="space-y-2">
160+
<Label htmlFor="revoke-sessions-secret-key">Secret key</Label>
161+
<Input
162+
autoComplete="off"
163+
className="bg-card"
164+
id="revoke-sessions-secret-key"
165+
onChange={(e) => setSecretKey(e.target.value)}
166+
placeholder="Project secret key"
167+
type="password"
168+
value={secretKey}
169+
/>
170+
</div>
171+
</div>
172+
</DangerSettingCard>
173+
);
174+
}

‎apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
77
import { loginRedirect } from "@/utils/redirects";
88
import { getSMSCountryTiers } from "./api/sms";
99
import { InAppWalletSettingsPage } from "./components";
10+
import { RevokeSessionsCard } from "./components/revoke-sessions-card";
1011

1112
export default async function Page(props: {
1213
params: Promise<{ team_slug: string; project_slug: string }>;
@@ -50,6 +51,14 @@ export default async function Page(props: {
5051
teamPlan={getValidTeamPlan(team)}
5152
teamSlug={team_slug}
5253
/>
54+
{project.services.some(
55+
(service) => service.name === "embeddedWallets",
56+
) && (
57+
<RevokeSessionsCard
58+
clientId={project.publishableKey}
59+
teamId={team.id}
60+
/>
61+
)}
5362
</div>
5463
);
5564
}

0 commit comments

Comments
 (0)