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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
58 changes: 58 additions & 0 deletions apps/dashboard/src/@/actions/createTeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use server";

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;
}): Promise<
| {
ok: true;
data: Team;
}
| {
ok: false;
errorMessage: string;
}
> {
const token = await getAuthToken();

if (!token) {
return {
ok: false,
errorMessage: "You are not authorized to perform this action",
};
}

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) {
return {
ok: false,
errorMessage: await res.text(),
};
}

const json = await res.json();

return {
ok: true,
data: json.result,
};
}
25 changes: 25 additions & 0 deletions apps/dashboard/src/@/actions/deleteTeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"use server";
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;
}): Promise<void> {
const token = await getAuthToken();
if (!token) {
throw new Error("You are not authorized to perform this action");
}
Comment on lines +5 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider input validation and improve error handling consistency

The function signature and authorization check are good, but there are a few areas for improvement:

  1. Input validation: The teamId parameter is not validated. Consider adding validation to ensure it's a non-empty string.
  2. Error handling inconsistency: This action throws errors, while createTeam returns result objects with ok boolean. This inconsistency could confuse consumers.

Consider aligning the error handling pattern with createTeam:

export async function deleteTeam(options: {
  teamId: string;
}): Promise<
  | { ok: true }
  | { ok: false; errorMessage: string }
> {
  const token = await getAuthToken();
  if (!token) {
-   throw new Error("You are not authorized to perform this action");
+   return {
+     ok: false,
+     errorMessage: "You are not authorized to perform this action"
+   };
  }
+
+ if (!options.teamId?.trim()) {
+   return {
+     ok: false,
+     errorMessage: "Team ID is required"
+   };
+ }
🤖 Prompt for AI Agents
In apps/dashboard/src/@/actions/deleteTeam.ts around lines 5 to 11, add
validation to check that the teamId parameter is a non-empty string before
proceeding. Also, refactor the function to return a result object with an ok
boolean and error message instead of throwing errors, to align with the error
handling pattern used in createTeam. This will improve input validation and
maintain consistency in error handling across similar functions.


const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
throw new Error(await res.text());
}
}
Comment on lines +13 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consider enhanced error handling and security measures

The API call implementation is functional but could be improved:

  1. Error handling: Currently throws generic errors. Consider providing more specific error messages based on response status.
  2. Permission verification: The action only checks authentication but doesn't verify if the user has permission to delete the specific team.

Consider these improvements:

  const res = await fetch(
    `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
    {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${token}`,
      },
    },
  );
  if (!res.ok) {
-   throw new Error(await res.text());
+   const errorText = await res.text();
+   return {
+     ok: false,
+     errorMessage: res.status === 403 
+       ? "You don't have permission to delete this team"
+       : res.status === 404
+       ? "Team not found"
+       : errorText || "Failed to delete team"
+   };
  }
+ return { ok: true };

Let me verify if there are any existing permission checks for team deletion in the codebase:


🏁 Script executed:

#!/bin/bash
# Search for team permission/authorization patterns
rg -A 5 -B 5 "delete.*team|team.*delete" --type ts

Length of output: 9979


🏁 Script executed:

#!/bin/bash
# Display contents of deleteTeam action file for context
cat apps/dashboard/src/@/actions/deleteTeam.ts

Length of output: 683


Enhance error reporting in deleteTeam action

The server enforces authorization and team-deletion permissions; we only need to map common failure statuses to more descriptive errors rather than return a new result type. In apps/dashboard/src/@/actions/deleteTeam.ts, update the error block as follows:

   const res = await fetch(
     `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
     {
       method: "DELETE",
       headers: {
         Authorization: `Bearer ${token}`,
       },
     },
   );
-  if (!res.ok) {
-    throw new Error(await res.text());
-  }
+  if (!res.ok) {
+    const errorText = await res.text();
+    if (res.status === 403) {
+      throw new Error("You don’t have permission to delete this team");
+    }
+    if (res.status === 404) {
+      throw new Error("Team not found");
+    }
+    throw new Error(errorText || "Failed to delete team");
+  }

• Preserves the existing Promise<void> signature
• Leverages server-side permission checks and surfaces clear client-side messages
• UI’s toast.promise will display the thrown message automatically

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
throw new Error(await res.text());
}
}
const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
const errorText = await res.text();
if (res.status === 403) {
throw new Error("You don’t have permission to delete this team");
}
if (res.status === 404) {
throw new Error("Team not found");
}
throw new Error(errorText || "Failed to delete team");
}
}
🤖 Prompt for AI Agents
In apps/dashboard/src/@/actions/deleteTeam.ts around lines 13 to 25, improve
error handling by mapping common HTTP failure statuses to more descriptive error
messages instead of throwing a generic error. Keep the Promise<void> signature
and rely on server-side permission enforcement, but update the error block to
check res.status and throw specific errors like "Unauthorized" for 401,
"Forbidden" for 403, or a detailed message from the response text for other
failures. This will allow the UI toast.promise to display clearer messages
automatically.

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 @@ -57,6 +59,14 @@ export function AccountHeader(props: {
team,
isOpen: true,
}),
createTeam: async () => {
const result = await createTeam({});
if (!result.ok) {
toast.error("Failed to create team");
return;
}
router.push(`/team/${result.data.slug}`);
},
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 @@ -21,6 +21,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 @@ -64,6 +65,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 @@ -117,6 +119,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,15 @@ export function AccountTeamsUI(props: {
.includes(teamSearchValue.toLowerCase());
});

const createTeamAndRedirect = async () => {
const result = await createTeam({});
if (!result.ok) {
toast.error("Failed to create team");
return;
}
router.push(`/team/${result.data.slug}`);
};

return (
<div>
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
Expand All @@ -45,12 +57,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,40 @@ 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");
await deleteTeam({ teamId: props.teamId });
},
onSuccess: () => {
router.push("/team");
},
});

function handleDelete() {
const promise = deleteTeam.mutateAsync();
const promise = deleteTeamAndRedirect.mutateAsync();
toast.promise(promise, {
success: "Team deleted successfully",
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type TeamSwitcherProps = {
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
focus: "project-selection" | "team-selection";
createProject: (team: Team) => void;
createTeam: () => void;
account: Pick<Account, "email" | "id"> | undefined;
client: ThirdwebClient;
};
Expand Down Expand Up @@ -100,6 +101,10 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
}
account={props.account}
client={props.client}
createTeam={() => {
setOpen(false);
props.createTeam();
}}
/>

{/* Right */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ function Variant(props: {
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
createTeam={() => {}}
client={storybookThirdwebClient}
getInboxNotifications={getInboxNotificationsStub}
markNotificationAsRead={markNotificationAsReadStub}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type TeamHeaderCompProps = {
logout: () => void;
connectButton: React.ReactNode;
createProject: (team: Team) => void;
createTeam: () => void;
client: ThirdwebClient;
accountAddress: string;
getInboxNotifications: () => Promise<NotificationMetadata[]>;
Expand Down Expand Up @@ -75,6 +76,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
teamsAndProjects={props.teamsAndProjects}
focus="team-selection"
createProject={props.createProject}
createTeam={props.createTeam}
account={props.account}
client={props.client}
/>
Expand Down Expand Up @@ -102,6 +104,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
teamsAndProjects={props.teamsAndProjects}
focus="project-selection"
createProject={props.createProject}
createTeam={props.createTeam}
account={props.account}
client={props.client}
/>
Expand Down Expand Up @@ -170,6 +173,9 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
upgradeTeamLink={`/team/${currentTeam.slug}/settings`}
account={props.account}
client={props.client}
createTeam={() => {
alert("createTeam");
}}
Comment on lines +176 to +178
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace placeholder alert with proper createTeam implementation.

The current alert implementation appears to be placeholder code. This should use the actual createTeam prop passed from the parent component for consistency with other UI components.

Apply this diff to use the proper callback:

-            createTeam={() => {
-              alert("createTeam");
-            }}
+            createTeam={props.createTeam}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
createTeam={() => {
alert("createTeam");
}}
createTeam={props.createTeam}
🤖 Prompt for AI Agents
In apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx
around lines 176 to 178, replace the placeholder alert in the createTeam prop
with the actual createTeam function passed down as a prop from the parent
component. This ensures the component uses the proper callback for creating a
team instead of just showing an alert.

/>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
Expand All @@ -25,6 +24,7 @@ export function TeamSelectionUI(props: {
account: Pick<Account, "email" | "id" | "image"> | undefined;
client: ThirdwebClient;
isOnProjectPage: boolean;
createTeam: () => void;
}) {
const { setHoveredTeam, currentTeam, teamsAndProjects } = props;
const pathname = usePathname();
Expand Down Expand Up @@ -127,15 +127,12 @@ export function TeamSelectionUI(props: {

<li className="py-0.5">
<Button
className="w-full justify-start gap-2 px-2 disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-100"
className="w-full justify-start gap-2 px-2"
variant="ghost"
disabled
onClick={props.createTeam}
>
<CirclePlusIcon className="size-4 text-link-foreground" />
Create Team
<Badge className="ml-auto" variant="secondary">
Soon™️
</Badge>
</Button>
</li>
</ul>
Expand Down
Loading
Loading