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
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"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<RevokeSessionsResult> {
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";

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",
},
);
} catch {
return { error: "Failed to reach the wallet service", success: false };
}

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,
};
}
Original file line number Diff line number Diff line change
@@ -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<IdentifierType>("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 (
<DangerSettingCard
buttonLabel="Revoke sessions"
buttonOnClick={() => {
if (isReady) {
revokeSessions.mutate();
}
}}
confirmationDialog={{
children: revokeSessions.data ? (
<Alert className="mt-4" variant="info">
<AlertTitle>Sessions revoked</AlertTitle>
<AlertDescription>
{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()}
.
</AlertDescription>
</Alert>
) : 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"
>
<div className="space-y-5">
<RadioGroup
className="flex flex-col gap-3 lg:flex-row"
onValueChange={(value) => setScope(value as "user" | "all")}
value={scope}
>
<RadioGroupItemButton id="revoke-scope-user" value="user">
A single user
</RadioGroupItemButton>
<RadioGroupItemButton id="revoke-scope-all" value="all">
All users
</RadioGroupItemButton>
</RadioGroup>

{scope === "user" && (
<div className="flex flex-col gap-3 md:flex-row">
<Select
onValueChange={(value) =>
setIdentifierType(value as IdentifierType)
}
value={identifierType}
>
<SelectTrigger className="bg-card md:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{identifierOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
aria-label={selectedOption?.label}
className="bg-card"
onChange={(e) => setIdentifier(e.target.value)}
placeholder={selectedOption?.placeholder}
value={identifier}
/>
</div>
)}

<div className="space-y-2">
<Label htmlFor="revoke-sessions-secret-key">Secret key</Label>
<Input
autoComplete="off"
className="bg-card"
id="revoke-sessions-secret-key"
onChange={(e) => setSecretKey(e.target.value)}
placeholder="Project secret key"
type="password"
value={secretKey}
/>
</div>
</div>
</DangerSettingCard>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -50,6 +51,14 @@ export default async function Page(props: {
teamPlan={getValidTeamPlan(team)}
teamSlug={team_slug}
/>
{project.services.some(
(service) => service.name === "embeddedWallets",
) && (
<RevokeSessionsCard
clientId={project.publishableKey}
teamId={team.id}
/>
)}
</div>
);
}
Loading