From 9643b8d2c591552e6b44f515619e9e7dd970076f Mon Sep 17 00:00:00 2001 From: arcoraven Date: Tue, 10 Jun 2025 01:44:58 +0000 Subject: [PATCH] feat: create/delete teams (#7293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # [Dashboard] Feature: Add Team Creation and Deletion ## Notes for the reviewer This PR adds the ability for users to create and delete teams from the dashboard. It includes: 1. Server actions for creating and deleting teams 2. UI integration in the account header, team selector, and account teams page 3. Team deletion functionality in the team settings page ## How to test - Try creating a new team from the account header dropdown - Try creating a new team from the account teams page - Try deleting a team from the team settings page (only available for team owners) ## Summary by CodeRabbit - **New Features** - Enabled team creation directly from the account and team selection interfaces, allowing users to create a new team and be redirected to its page immediately. - Activated the "Create Team" button in relevant menus and headers, making team creation accessible across both desktop and mobile views. - **Bug Fixes** - Improved error handling and user feedback with toast notifications when team creation fails. - **Refactor** - Updated team deletion to use real permission checks and improved the user interface for deleting teams. - Added comprehensive handling of authorization and error messages for team creation and deletion operations. - Redirected users to the account page when no teams are available. - **Documentation** - Marked the default team retrieval function as deprecated in the documentation. --- ## PR-Codex overview This PR primarily focuses on enhancing team management functionalities within the application. It introduces the ability to create and delete teams, updates various UI components to accommodate these changes, and ensures proper redirection and error handling during team operations. ### Detailed summary - Added `createTeam` function in `createTeam.ts` for team creation. - Introduced `deleteTeam` function in `deleteTeam.ts` for team deletion. - Updated UI components to include `createTeam` functionality. - Modified `TeamHeaderLoggedIn`, `AccountHeader`, and other components to handle team creation and deletion. - Implemented redirection upon successful team creation and deletion. - Updated `DeleteTeamCard` and `TeamGeneralSettingsPageUI` to manage permissions for team deletion. - Enhanced error handling and user feedback with toast notifications during team operations. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/src/@/actions/createTeam.ts | 74 +++++++++++++++++++ apps/dashboard/src/@/actions/deleteTeam.ts | 70 ++++++++++++++++++ apps/dashboard/src/@/api/team.ts | 1 + .../account/components/AccountHeader.tsx | 17 +++++ .../components/AccountHeaderUI.stories.tsx | 1 + .../account/components/AccountHeaderUI.tsx | 3 + .../(app)/account/overview/AccountTeamsUI.tsx | 31 ++++++-- .../general/GeneralSettingsPage.stories.tsx | 4 +- .../general/TeamGeneralSettingsPageUI.tsx | 26 ++++--- .../TeamAndProjectSelectorPopoverButton.tsx | 5 ++ .../TeamHeader/TeamHeaderUI.stories.tsx | 1 + .../components/TeamHeader/TeamHeaderUI.tsx | 4 + .../components/TeamHeader/TeamSelectionUI.tsx | 9 +-- .../TeamSelectorMobileMenuButton.tsx | 2 + .../team-header-logged-in.client.tsx | 17 +++++ .../app/(app)/team/~/[[...paths]]/page.tsx | 6 ++ 16 files changed, 244 insertions(+), 27 deletions(-) create mode 100644 apps/dashboard/src/@/actions/createTeam.ts create mode 100644 apps/dashboard/src/@/actions/deleteTeam.ts diff --git a/apps/dashboard/src/@/actions/createTeam.ts b/apps/dashboard/src/@/actions/createTeam.ts new file mode 100644 index 00000000000..06a85faa766 --- /dev/null +++ b/apps/dashboard/src/@/actions/createTeam.ts @@ -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; +} diff --git a/apps/dashboard/src/@/actions/deleteTeam.ts b/apps/dashboard/src/@/actions/deleteTeam.ts new file mode 100644 index 00000000000..bdfa2fe3f43 --- /dev/null +++ b/apps/dashboard/src/@/actions/deleteTeam.ts @@ -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; +} diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index 798e9de2d6e..5656535e520 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -67,6 +67,7 @@ export async function getTeams() { return null; } +/** @deprecated */ export async function getDefaultTeam() { const token = await getAuthToken(); if (!token) { diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx index a9b0465f5e7..aef54edfbee 100644 --- a/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx +++ b/apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import { createTeam } from "@/actions/createTeam"; import type { Project } from "@/api/projects"; import type { Team } from "@/api/team"; import { useDashboardRouter } from "@/lib/DashboardRouter"; @@ -7,6 +8,7 @@ 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"; @@ -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, diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx index 3e6bea2daf4..5393a79816b 100644 --- a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx +++ b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx @@ -59,6 +59,7 @@ function Variants(props: { accountAddress={accountAddressStub} connectButton={} createProject={() => {}} + createTeam={() => {}} account={{ id: "foo", email: "foo@example.com", diff --git a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx index 9ab986ead91..88f44b06f5d 100644 --- a/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx +++ b/apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx @@ -18,6 +18,7 @@ export type AccountHeaderCompProps = { connectButton: React.ReactNode; teamsAndProjects: Array<{ team: Team; projects: Project[] }>; createProject: (team: Team) => void; + createTeam: () => void; account: Pick; client: ThirdwebClient; accountAddress: string; @@ -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} /> @@ -110,6 +112,7 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) { upgradeTeamLink={undefined} account={props.account} client={props.client} + createTeam={props.createTeam} /> )} diff --git a/apps/dashboard/src/app/(app)/account/overview/AccountTeamsUI.tsx b/apps/dashboard/src/app/(app)/account/overview/AccountTeamsUI.tsx index fc1b6cc3d2c..30090f14bc6 100644 --- a/apps/dashboard/src/app/(app)/account/overview/AccountTeamsUI.tsx +++ b/apps/dashboard/src/app/(app)/account/overview/AccountTeamsUI.tsx @@ -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"; @@ -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"; @@ -26,6 +28,7 @@ export function AccountTeamsUI(props: { }[]; client: ThirdwebClient; }) { + const router = useDashboardRouter(); const [teamSearchValue, setTeamSearchValue] = useState(""); const teamsToShow = !teamSearchValue ? props.teamsWithRole @@ -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 (
@@ -45,12 +64,10 @@ export function AccountTeamsUI(props: {

- - - +
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx index 1664e6bd243..a6bbb283d6b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx @@ -62,8 +62,8 @@ function ComponentVariants() { await new Promise((resolve) => setTimeout(resolve, 1000)); }} /> - - + +
diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index 0c41ef66751..09c8dc6b90b 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -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"; @@ -35,7 +36,6 @@ export function TeamGeneralSettingsPageUI(props: { client: ThirdwebClient; leaveTeam: () => Promise; }) { - const hasPermissionToDelete = false; // TODO return (
); @@ -293,7 +294,8 @@ export function LeaveTeamCard(props: { } export function DeleteTeamCard(props: { - enabled: boolean; + canDelete: boolean; + teamId: string; teamName: string; }) { const router = useDashboardRouter(); @@ -301,12 +303,12 @@ export function DeleteTeamCard(props: { 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"); @@ -314,21 +316,21 @@ export function DeleteTeamCard(props: { }); 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 ( ; focus: "project-selection" | "team-selection"; createProject: (team: Team) => void; + createTeam: () => void; account: Pick | undefined; client: ThirdwebClient; }; @@ -100,6 +101,10 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) { } account={props.account} client={props.client} + createTeam={() => { + setOpen(false); + props.createTeam(); + }} /> {/* Right */} diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx index 255d94a3879..c22b528ce29 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.stories.tsx @@ -152,6 +152,7 @@ function Variant(props: { logout={() => {}} connectButton={} createProject={() => {}} + createTeam={() => {}} client={storybookThirdwebClient} /> diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx index 66e0fa4a0fd..45d2a9c4597 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamHeaderUI.tsx @@ -26,6 +26,7 @@ export type TeamHeaderCompProps = { logout: () => void; connectButton: React.ReactNode; createProject: (team: Team) => void; + createTeam: () => void; client: ThirdwebClient; accountAddress: string; }; @@ -73,6 +74,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { teamsAndProjects={props.teamsAndProjects} focus="team-selection" createProject={props.createProject} + createTeam={props.createTeam} account={props.account} client={props.client} /> @@ -100,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) { teamsAndProjects={props.teamsAndProjects} focus="project-selection" createProject={props.createProject} + createTeam={props.createTeam} account={props.account} client={props.client} /> @@ -166,6 +169,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) { upgradeTeamLink={`/team/${currentTeam.slug}/settings`} account={props.account} client={props.client} + createTeam={props.createTeam} /> diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx index 0bd1f7b594f..7173e6e2a50 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectionUI.tsx @@ -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"; @@ -25,6 +24,7 @@ export function TeamSelectionUI(props: { account: Pick | undefined; client: ThirdwebClient; isOnProjectPage: boolean; + createTeam: () => void; }) { const { setHoveredTeam, currentTeam, teamsAndProjects } = props; const pathname = usePathname(); @@ -127,15 +127,12 @@ export function TeamSelectionUI(props: {
  • diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectorMobileMenuButton.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectorMobileMenuButton.tsx index 4bb36775210..001e8af9e58 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectorMobileMenuButton.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/TeamSelectorMobileMenuButton.tsx @@ -17,6 +17,7 @@ type TeamSelectorMobileMenuButtonProps = { account: Pick | undefined; client: ThirdwebClient; isOnProjectPage: boolean; + createTeam: () => void; }; export function TeamSelectorMobileMenuButton( @@ -51,6 +52,7 @@ export function TeamSelectorMobileMenuButton( upgradeTeamLink={props.upgradeTeamLink} account={props.account} client={props.client} + createTeam={props.createTeam} /> diff --git a/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-header-logged-in.client.tsx b/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-header-logged-in.client.tsx index 132748a4b55..ff3e2261675 100644 --- a/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-header-logged-in.client.tsx +++ b/apps/dashboard/src/app/(app)/team/components/TeamHeader/team-header-logged-in.client.tsx @@ -1,5 +1,6 @@ "use client"; +import { createTeam } from "@/actions/createTeam"; import type { Project } from "@/api/projects"; import type { Team } from "@/api/team"; import { useDashboardRouter } from "@/lib/DashboardRouter"; @@ -7,6 +8,7 @@ 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"; @@ -60,6 +62,21 @@ export function TeamHeaderLoggedIn(props: { team, }); }, + 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", + }, + ); + }, client: props.client, accountAddress: props.accountAddress, }; diff --git a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx index a3119dbb2b0..167fc8fb12a 100644 --- a/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx +++ b/apps/dashboard/src/app/(app)/team/~/[[...paths]]/page.tsx @@ -38,7 +38,13 @@ export default async function Page(props: { }) .join("&"); + // if the teams.length is ever 0, redirect to the account page (where the user can create a team then) + if (teams.length === 0) { + redirect("/account"); + } + // if there is a single team, redirect to the team page directly + if (teams.length === 1 && teams[0]) { redirect( createTeamLink({