From 7bfa56104f6f7f0d28eb0b2e4db724396c340555 Mon Sep 17 00:00:00 2001 From: firekeeper <0xfirekeeper@gmail.com> Date: Sat, 26 Sep 2026 04:54:14 +0700 Subject: [PATCH 1/2] [Dashboard] Add revoke sessions to user wallets configuration Co-Authored-By: Claude Opus 5.5 --- .../configuration/api/revoke-sessions.ts | 82 +++++++++ .../components/revoke-sessions-card.tsx | 174 ++++++++++++++++++ .../user-wallets/configuration/page.tsx | 9 + 3 files changed, 265 insertions(+) create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/revoke-sessions-card.tsx diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts new file mode 100644 index 00000000000..2e62486f518 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts @@ -0,0 +1,82 @@ +"use server"; +import "server-only"; + +import { getAuthToken } from "@/api/auth-token"; +import { THIRDWEB_INAPP_WALLET_DOMAIN } from "@/constants/urls"; + +export type RevokeSessionsTarget = + | { type: "email" | "phone" | "walletAddress" | "userId"; value: string } + | { type: "allUsers" }; + +type RevokeSessionsResult = + | { + success: true; + scope: "user" | "project"; + userCount: number; + tokensInvalidBefore: string; + } + | { success: false; error: string }; + +export async function revokeUserWalletSessions(params: { + teamId: string; + clientId: string; + secretKey: string; + target: RevokeSessionsTarget; +}): Promise { + const token = await getAuthToken(); + if (!token) { + return { error: "Unauthorized", success: false }; + } + + const secretKey = params.secretKey.trim(); + const { target } = params; + if (!secretKey || (target.type !== "allUsers" && !target.value.trim())) { + return { error: "Missing required fields", success: false }; + } + + const protocol = THIRDWEB_INAPP_WALLET_DOMAIN.startsWith("localhost") + ? "http" + : "https"; + + const res = await fetch( + `${protocol}://${THIRDWEB_INAPP_WALLET_DOMAIN}/api/v1/users/revoke-sessions`, + { + body: JSON.stringify({ + clientId: params.clientId, + secretKey, + ...(target.type === "allUsers" + ? { allUsers: true } + : { [target.type]: target.value.trim() }), + }), + cache: "no-store", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-thirdweb-team-id": params.teamId, + }, + method: "POST", + }, + ); + + const json = (await res.json().catch(() => null)) as { + message?: string; + scope?: "user" | "project"; + userIds?: string[]; + tokensInvalidBefore?: string; + } | null; + + if (!res.ok || !json?.scope || !json.tokensInvalidBefore) { + return { + error: json?.message || `Request failed with status ${res.status}`, + success: false, + }; + } + + return { + scope: json.scope, + success: true, + tokensInvalidBefore: json.tokensInvalidBefore, + userCount: json.userIds?.length ?? 0, + }; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/revoke-sessions-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/revoke-sessions-card.tsx new file mode 100644 index 00000000000..240faf3c68d --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/components/revoke-sessions-card.tsx @@ -0,0 +1,174 @@ +"use client"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItemButton } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + type RevokeSessionsTarget, + revokeUserWalletSessions, +} from "../api/revoke-sessions"; + +type IdentifierType = "email" | "phone" | "walletAddress" | "userId"; + +const identifierOptions: { + value: IdentifierType; + label: string; + placeholder: string; +}[] = [ + { label: "Email", placeholder: "user@example.com", value: "email" }, + { label: "Phone", placeholder: "+15555555555", value: "phone" }, + { label: "Wallet address", placeholder: "0x...", value: "walletAddress" }, + { label: "User ID", placeholder: "User ID", value: "userId" }, +]; + +export function RevokeSessionsCard(props: { + clientId: string; + teamId: string; +}) { + const [scope, setScope] = useState<"user" | "all">("user"); + const [identifierType, setIdentifierType] = useState("email"); + const [identifier, setIdentifier] = useState(""); + const [secretKey, setSecretKey] = useState(""); + + const selectedOption = identifierOptions.find( + (option) => option.value === identifierType, + ); + const isReady = + secretKey.trim().length > 0 && + (scope === "all" || identifier.trim().length > 0); + + const revokeSessions = useMutation({ + mutationFn: async () => { + const target: RevokeSessionsTarget = + scope === "all" + ? { type: "allUsers" } + : { type: identifierType, value: identifier }; + const result = await revokeUserWalletSessions({ + clientId: props.clientId, + secretKey, + target, + teamId: props.teamId, + }); + if (!result.success) { + throw new Error(result.error); + } + return result; + }, + onError: (error) => { + toast.error(error.message || "Failed to revoke sessions"); + }, + onSuccess: () => { + setSecretKey(""); + toast.success("Sessions revoked"); + }, + }); + + return ( + { + if (isReady) { + revokeSessions.mutate(); + } + }} + confirmationDialog={{ + children: revokeSessions.data ? ( + + Sessions revoked + + {revokeSessions.data.scope === "project" + ? "All users" + : `${revokeSessions.data.userCount} user${revokeSessions.data.userCount === 1 ? "" : "s"}`}{" "} + signed out as of{" "} + {new Date( + revokeSessions.data.tokensInvalidBefore, + ).toLocaleString()} + . + + + ) : null, + description: + scope === "all" + ? "Every user of this project will be signed out of all devices." + : `${selectedOption?.label}: ${identifier.trim()}`, + onClose: () => revokeSessions.reset(), + title: + scope === "all" + ? "Revoke sessions for all users?" + : "Revoke sessions for this user?", + }} + description="Sign users out of every device. Existing sessions stop working immediately and users can sign in again." + isDisabled={!isReady} + isPending={revokeSessions.isPending} + title="Revoke sessions" + > +
+ setScope(value as "user" | "all")} + value={scope} + > + + A single user + + + All users + + + + {scope === "user" && ( +
+ + setIdentifier(e.target.value)} + placeholder={selectedOption?.placeholder} + value={identifier} + /> +
+ )} + +
+ + setSecretKey(e.target.value)} + placeholder="Project secret key" + type="password" + value={secretKey} + /> +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx index ba1d35c2a96..21675deab31 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/page.tsx @@ -7,6 +7,7 @@ import { getValidTeamPlan } from "@/utils/getValidTeamPlan"; import { loginRedirect } from "@/utils/redirects"; import { getSMSCountryTiers } from "./api/sms"; import { InAppWalletSettingsPage } from "./components"; +import { RevokeSessionsCard } from "./components/revoke-sessions-card"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; @@ -50,6 +51,14 @@ export default async function Page(props: { teamPlan={getValidTeamPlan(team)} teamSlug={team_slug} /> + {project.services.some( + (service) => service.name === "embeddedWallets", + ) && ( + + )} ); } From d3c8b56367a5b5235e9857ba26dd5b1283f050bc Mon Sep 17 00:00:00 2001 From: firekeeper <0xfirekeeper@gmail.com> Date: Sat, 26 Sep 2026 05:21:59 +0700 Subject: [PATCH 2/2] Handle fetch failure Co-Authored-By: Claude Opus 5.5 --- .../configuration/api/revoke-sessions.ts | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts index 2e62486f518..b1a20c0c799 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/user-wallets/configuration/api/revoke-sessions.ts @@ -38,26 +38,31 @@ export async function revokeUserWalletSessions(params: { ? "http" : "https"; - const res = await fetch( - `${protocol}://${THIRDWEB_INAPP_WALLET_DOMAIN}/api/v1/users/revoke-sessions`, - { - body: JSON.stringify({ - clientId: params.clientId, - secretKey, - ...(target.type === "allUsers" - ? { allUsers: true } - : { [target.type]: target.value.trim() }), - }), - cache: "no-store", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "x-client-id": params.clientId, - "x-thirdweb-team-id": params.teamId, + let res: Response; + try { + res = await fetch( + `${protocol}://${THIRDWEB_INAPP_WALLET_DOMAIN}/api/v1/users/revoke-sessions`, + { + body: JSON.stringify({ + clientId: params.clientId, + secretKey, + ...(target.type === "allUsers" + ? { allUsers: true } + : { [target.type]: target.value.trim() }), + }), + cache: "no-store", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "x-client-id": params.clientId, + "x-thirdweb-team-id": params.teamId, + }, + method: "POST", }, - method: "POST", - }, - ); + ); + } catch { + return { error: "Failed to reach the wallet service", success: false }; + } const json = (await res.json().catch(() => null)) as { message?: string;