Skip to content

feat: create/delete teams #7293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 10, 2025
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
74 changes: 74 additions & 0 deletions apps/dashboard/src/@/actions/createTeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use server";
import "server-only";

import { randomBytes } from "node:crypto";
import type { Team } from "@/api/team";
import { format } from "date-fns";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";

export async function createTeam(options?: {
name?: string;
slug?: string;
}) {
const token = await getAuthToken();

if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action",
} as const;
}

const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name:
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
slug: options?.slug ?? randomBytes(20).toString("hex"),
billingEmail: null,
image: null,
}),
});

if (!res.ok) {
const reason = await res.text();
console.error("failed to create team", {
status: res.status,
reason,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team name or slug.",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
} as const;
}
}
}

const json = (await res.json()) as {
result: Team;
};

return {
status: "success",
data: json.result,
} as const;
}
70 changes: 70 additions & 0 deletions apps/dashboard/src/@/actions/deleteTeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use server";
import "server-only";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";

export async function deleteTeam(options: {
teamId: string;
}) {
const token = await getAuthToken();
if (!token) {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}

const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
},
);
// handle errors
if (!res.ok) {
const reason = await res.text();
console.error("failed to delete team", {
status: res.status,
reason,
});
switch (res.status) {
case 400: {
return {
status: "error",
errorMessage: "Invalid team ID.",
} as const;
}
case 401: {
return {
status: "error",
errorMessage: "You are not authorized to perform this action.",
} as const;
}

case 403: {
return {
status: "error",
errorMessage: "You do not have permission to delete this team.",
} as const;
}
case 404: {
return {
status: "error",
errorMessage: "Team not found.",
} as const;
}
default: {
return {
status: "error",
errorMessage: "An unknown error occurred.",
} as const;
}
}
}
return {
status: "success",
} as const;
}
1 change: 1 addition & 0 deletions apps/dashboard/src/@/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function getTeams() {
return null;
}

/** @deprecated */
export async function getDefaultTeam() {
const token = await getAuthToken();
if (!token) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { createTeam } from "@/actions/createTeam";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { doLogout } from "../../login/auth-actions";
Expand Down Expand Up @@ -53,6 +55,21 @@ export function AccountHeader(props: {
team,
isOpen: true,
}),
createTeam: () => {
toast.promise(
createTeam().then((res) => {
if (res.status === "error") {
throw new Error(res.errorMessage);
}
router.push(`/team/${res.data.slug}`);
}),
{
loading: "Creating team",
success: "Team created",
error: "Failed to create team",
},
);
},
account: props.account,
client: props.client,
accountAddress: props.accountAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function Variants(props: {
accountAddress={accountAddressStub}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
createTeam={() => {}}
account={{
id: "foo",
email: "[email protected]",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type AccountHeaderCompProps = {
connectButton: React.ReactNode;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
createProject: (team: Team) => void;
createTeam: () => void;
account: Pick<Account, "email" | "id" | "image">;
client: ThirdwebClient;
accountAddress: string;
Expand Down Expand Up @@ -59,6 +60,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
teamsAndProjects={props.teamsAndProjects}
focus="team-selection"
createProject={props.createProject}
createTeam={props.createTeam}
account={props.account}
client={props.client}
/>
Expand Down Expand Up @@ -110,6 +112,7 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
upgradeTeamLink={undefined}
account={props.account}
client={props.client}
createTeam={props.createTeam}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { createTeam } from "@/actions/createTeam";
import type { Team } from "@/api/team";
import type { TeamAccountRole } from "@/api/team-members";
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
Expand All @@ -10,10 +11,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { EllipsisIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ThirdwebClient } from "thirdweb";
import { TeamPlanBadge } from "../../components/TeamPlanBadge";
import { getValidTeamPlan } from "../../team/components/TeamHeader/getValidTeamPlan";
Expand All @@ -26,6 +28,7 @@ export function AccountTeamsUI(props: {
}[];
client: ThirdwebClient;
}) {
const router = useDashboardRouter();
const [teamSearchValue, setTeamSearchValue] = useState("");
const teamsToShow = !teamSearchValue
? props.teamsWithRole
Expand All @@ -35,6 +38,22 @@ export function AccountTeamsUI(props: {
.includes(teamSearchValue.toLowerCase());
});

const createTeamAndRedirect = () => {
toast.promise(
createTeam().then((res) => {
if (res.status === "error") {
throw new Error(res.errorMessage);
}
router.push(`/team/${res.data.slug}`);
}),
{
loading: "Creating team",
success: "Team created",
error: "Failed to create team",
},
);
};

return (
<div>
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
Expand All @@ -45,12 +64,10 @@ export function AccountTeamsUI(props: {
</p>
</div>

<ToolTipLabel label="Coming Soon">
<Button disabled className="gap-2 max-sm:w-full">
<PlusIcon className="size-4" />
Create Team
</Button>
</ToolTipLabel>
<Button className="gap-2 max-sm:w-full" onClick={createTeamAndRedirect}>
<PlusIcon className="size-4" />
Create Team
</Button>
</div>

<div className="h-4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ function ComponentVariants() {
await new Promise((resolve) => setTimeout(resolve, 1000));
}}
/>
<DeleteTeamCard enabled={true} teamName="foo" />
<DeleteTeamCard enabled={false} teamName="foo" />
<DeleteTeamCard canDelete={true} teamId="1" teamName="foo" />
<DeleteTeamCard canDelete={false} teamId="2" teamName="foo" />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { deleteTeam } from "@/actions/deleteTeam";
import type { Team } from "@/api/team";
import type { VerifiedDomainResponse } from "@/api/verified-domain";
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
Expand Down Expand Up @@ -35,7 +36,6 @@ export function TeamGeneralSettingsPageUI(props: {
client: ThirdwebClient;
leaveTeam: () => Promise<void>;
}) {
const hasPermissionToDelete = false; // TODO
return (
<div className="flex flex-col gap-8">
<TeamNameFormControl
Expand All @@ -60,8 +60,9 @@ export function TeamGeneralSettingsPageUI(props: {

<LeaveTeamCard teamName={props.team.name} leaveTeam={props.leaveTeam} />
<DeleteTeamCard
enabled={hasPermissionToDelete}
teamId={props.team.id}
teamName={props.team.name}
canDelete={props.isOwnerAccount}
/>
</div>
);
Expand Down Expand Up @@ -293,42 +294,43 @@ export function LeaveTeamCard(props: {
}

export function DeleteTeamCard(props: {
enabled: boolean;
canDelete: boolean;
teamId: string;
teamName: string;
}) {
const router = useDashboardRouter();
const title = "Delete Team";
const description =
"Permanently remove your team and all of its contents from the thirdweb platform. This action is not reversible - please continue with caution.";

// TODO
const deleteTeam = useMutation({
const deleteTeamAndRedirect = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting team");
throw new Error("Not implemented");
const result = await deleteTeam({ teamId: props.teamId });
if (result.status === "error") {
throw new Error(result.errorMessage);
}
},
onSuccess: () => {
router.push("/team");
},
});

function handleDelete() {
const promise = deleteTeam.mutateAsync();
const promise = deleteTeamAndRedirect.mutateAsync();
toast.promise(promise, {
success: "Team deleted successfully",
success: "Team deleted",
error: "Failed to delete team",
});
}

if (props.enabled) {
if (props.canDelete) {
return (
<DangerSettingCard
title={title}
description={description}
buttonLabel={title}
buttonOnClick={handleDelete}
isPending={deleteTeam.isPending}
isPending={deleteTeamAndRedirect.isPending}
confirmationDialog={{
title: `Are you sure you want to delete team "${props.teamName}" ?`,
description: description,
Expand Down
Loading
Loading