Skip to content

Commit 9643b8d

Browse files
committed
feat: create/delete teams (#7293)
# [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) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- start pr-codex --> --- ## 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}` <!-- end pr-codex -->
1 parent 56d27d4 commit 9643b8d

File tree

16 files changed

+244
-27
lines changed

16 files changed

+244
-27
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use server";
2+
import "server-only";
3+
4+
import { randomBytes } from "node:crypto";
5+
import type { Team } from "@/api/team";
6+
import { format } from "date-fns";
7+
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
8+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
9+
10+
export async function createTeam(options?: {
11+
name?: string;
12+
slug?: string;
13+
}) {
14+
const token = await getAuthToken();
15+
16+
if (!token) {
17+
return {
18+
status: "error",
19+
errorMessage: "You are not authorized to perform this action",
20+
} as const;
21+
}
22+
23+
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
24+
method: "POST",
25+
headers: {
26+
Authorization: `Bearer ${token}`,
27+
"Content-Type": "application/json",
28+
},
29+
body: JSON.stringify({
30+
name:
31+
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
32+
slug: options?.slug ?? randomBytes(20).toString("hex"),
33+
billingEmail: null,
34+
image: null,
35+
}),
36+
});
37+
38+
if (!res.ok) {
39+
const reason = await res.text();
40+
console.error("failed to create team", {
41+
status: res.status,
42+
reason,
43+
});
44+
switch (res.status) {
45+
case 400: {
46+
return {
47+
status: "error",
48+
errorMessage: "Invalid team name or slug.",
49+
} as const;
50+
}
51+
case 401: {
52+
return {
53+
status: "error",
54+
errorMessage: "You are not authorized to perform this action.",
55+
} as const;
56+
}
57+
default: {
58+
return {
59+
status: "error",
60+
errorMessage: "An unknown error occurred.",
61+
} as const;
62+
}
63+
}
64+
}
65+
66+
const json = (await res.json()) as {
67+
result: Team;
68+
};
69+
70+
return {
71+
status: "success",
72+
data: json.result,
73+
} as const;
74+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use server";
2+
import "server-only";
3+
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
4+
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
5+
6+
export async function deleteTeam(options: {
7+
teamId: string;
8+
}) {
9+
const token = await getAuthToken();
10+
if (!token) {
11+
return {
12+
status: "error",
13+
errorMessage: "You are not authorized to perform this action.",
14+
} as const;
15+
}
16+
17+
const res = await fetch(
18+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
19+
{
20+
method: "DELETE",
21+
headers: {
22+
Authorization: `Bearer ${token}`,
23+
},
24+
},
25+
);
26+
// handle errors
27+
if (!res.ok) {
28+
const reason = await res.text();
29+
console.error("failed to delete team", {
30+
status: res.status,
31+
reason,
32+
});
33+
switch (res.status) {
34+
case 400: {
35+
return {
36+
status: "error",
37+
errorMessage: "Invalid team ID.",
38+
} as const;
39+
}
40+
case 401: {
41+
return {
42+
status: "error",
43+
errorMessage: "You are not authorized to perform this action.",
44+
} as const;
45+
}
46+
47+
case 403: {
48+
return {
49+
status: "error",
50+
errorMessage: "You do not have permission to delete this team.",
51+
} as const;
52+
}
53+
case 404: {
54+
return {
55+
status: "error",
56+
errorMessage: "Team not found.",
57+
} as const;
58+
}
59+
default: {
60+
return {
61+
status: "error",
62+
errorMessage: "An unknown error occurred.",
63+
} as const;
64+
}
65+
}
66+
}
67+
return {
68+
status: "success",
69+
} as const;
70+
}

apps/dashboard/src/@/api/team.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export async function getTeams() {
6767
return null;
6868
}
6969

70+
/** @deprecated */
7071
export async function getDefaultTeam() {
7172
const token = await getAuthToken();
7273
if (!token) {

apps/dashboard/src/app/(app)/account/components/AccountHeader.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"use client";
22

3+
import { createTeam } from "@/actions/createTeam";
34
import type { Project } from "@/api/projects";
45
import type { Team } from "@/api/team";
56
import { useDashboardRouter } from "@/lib/DashboardRouter";
67
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
78
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
89
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
910
import { useCallback, useState } from "react";
11+
import { toast } from "sonner";
1012
import type { ThirdwebClient } from "thirdweb";
1113
import { useActiveWallet, useDisconnect } from "thirdweb/react";
1214
import { doLogout } from "../../login/auth-actions";
@@ -53,6 +55,21 @@ export function AccountHeader(props: {
5355
team,
5456
isOpen: true,
5557
}),
58+
createTeam: () => {
59+
toast.promise(
60+
createTeam().then((res) => {
61+
if (res.status === "error") {
62+
throw new Error(res.errorMessage);
63+
}
64+
router.push(`/team/${res.data.slug}`);
65+
}),
66+
{
67+
loading: "Creating team",
68+
success: "Team created",
69+
error: "Failed to create team",
70+
},
71+
);
72+
},
5673
account: props.account,
5774
client: props.client,
5875
accountAddress: props.accountAddress,

apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.stories.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function Variants(props: {
5959
accountAddress={accountAddressStub}
6060
connectButton={<ConnectButtonStub />}
6161
createProject={() => {}}
62+
createTeam={() => {}}
6263
account={{
6364
id: "foo",
6465
email: "foo@example.com",

apps/dashboard/src/app/(app)/account/components/AccountHeaderUI.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type AccountHeaderCompProps = {
1818
connectButton: React.ReactNode;
1919
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
2020
createProject: (team: Team) => void;
21+
createTeam: () => void;
2122
account: Pick<Account, "email" | "id" | "image">;
2223
client: ThirdwebClient;
2324
accountAddress: string;
@@ -59,6 +60,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
5960
teamsAndProjects={props.teamsAndProjects}
6061
focus="team-selection"
6162
createProject={props.createProject}
63+
createTeam={props.createTeam}
6264
account={props.account}
6365
client={props.client}
6466
/>
@@ -110,6 +112,7 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
110112
upgradeTeamLink={undefined}
111113
account={props.account}
112114
client={props.client}
115+
createTeam={props.createTeam}
113116
/>
114117
)}
115118
</div>

apps/dashboard/src/app/(app)/account/overview/AccountTeamsUI.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { createTeam } from "@/actions/createTeam";
34
import type { Team } from "@/api/team";
45
import type { TeamAccountRole } from "@/api/team-members";
56
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
@@ -10,10 +11,11 @@ import {
1011
DropdownMenuItem,
1112
DropdownMenuTrigger,
1213
} from "@/components/ui/dropdown-menu";
13-
import { ToolTipLabel } from "@/components/ui/tooltip";
14+
import { useDashboardRouter } from "@/lib/DashboardRouter";
1415
import { EllipsisIcon, PlusIcon } from "lucide-react";
1516
import Link from "next/link";
1617
import { useState } from "react";
18+
import { toast } from "sonner";
1719
import type { ThirdwebClient } from "thirdweb";
1820
import { TeamPlanBadge } from "../../components/TeamPlanBadge";
1921
import { getValidTeamPlan } from "../../team/components/TeamHeader/getValidTeamPlan";
@@ -26,6 +28,7 @@ export function AccountTeamsUI(props: {
2628
}[];
2729
client: ThirdwebClient;
2830
}) {
31+
const router = useDashboardRouter();
2932
const [teamSearchValue, setTeamSearchValue] = useState("");
3033
const teamsToShow = !teamSearchValue
3134
? props.teamsWithRole
@@ -35,6 +38,22 @@ export function AccountTeamsUI(props: {
3538
.includes(teamSearchValue.toLowerCase());
3639
});
3740

41+
const createTeamAndRedirect = () => {
42+
toast.promise(
43+
createTeam().then((res) => {
44+
if (res.status === "error") {
45+
throw new Error(res.errorMessage);
46+
}
47+
router.push(`/team/${res.data.slug}`);
48+
}),
49+
{
50+
loading: "Creating team",
51+
success: "Team created",
52+
error: "Failed to create team",
53+
},
54+
);
55+
};
56+
3857
return (
3958
<div>
4059
<div className="flex flex-col items-start gap-4 lg:flex-row lg:justify-between">
@@ -45,12 +64,10 @@ export function AccountTeamsUI(props: {
4564
</p>
4665
</div>
4766

48-
<ToolTipLabel label="Coming Soon">
49-
<Button disabled className="gap-2 max-sm:w-full">
50-
<PlusIcon className="size-4" />
51-
Create Team
52-
</Button>
53-
</ToolTipLabel>
67+
<Button className="gap-2 max-sm:w-full" onClick={createTeamAndRedirect}>
68+
<PlusIcon className="size-4" />
69+
Create Team
70+
</Button>
5471
</div>
5572

5673
<div className="h-4" />

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ function ComponentVariants() {
6262
await new Promise((resolve) => setTimeout(resolve, 1000));
6363
}}
6464
/>
65-
<DeleteTeamCard enabled={true} teamName="foo" />
66-
<DeleteTeamCard enabled={false} teamName="foo" />
65+
<DeleteTeamCard canDelete={true} teamId="1" teamName="foo" />
66+
<DeleteTeamCard canDelete={false} teamId="2" teamName="foo" />
6767
</div>
6868
</div>
6969
</div>

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { deleteTeam } from "@/actions/deleteTeam";
34
import type { Team } from "@/api/team";
45
import type { VerifiedDomainResponse } from "@/api/verified-domain";
56
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
@@ -35,7 +36,6 @@ export function TeamGeneralSettingsPageUI(props: {
3536
client: ThirdwebClient;
3637
leaveTeam: () => Promise<void>;
3738
}) {
38-
const hasPermissionToDelete = false; // TODO
3939
return (
4040
<div className="flex flex-col gap-8">
4141
<TeamNameFormControl
@@ -60,8 +60,9 @@ export function TeamGeneralSettingsPageUI(props: {
6060

6161
<LeaveTeamCard teamName={props.team.name} leaveTeam={props.leaveTeam} />
6262
<DeleteTeamCard
63-
enabled={hasPermissionToDelete}
63+
teamId={props.team.id}
6464
teamName={props.team.name}
65+
canDelete={props.isOwnerAccount}
6566
/>
6667
</div>
6768
);
@@ -293,42 +294,43 @@ export function LeaveTeamCard(props: {
293294
}
294295

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

304-
// TODO
305-
const deleteTeam = useMutation({
306+
const deleteTeamAndRedirect = useMutation({
306307
mutationFn: async () => {
307-
await new Promise((resolve) => setTimeout(resolve, 3000));
308-
console.log("Deleting team");
309-
throw new Error("Not implemented");
308+
const result = await deleteTeam({ teamId: props.teamId });
309+
if (result.status === "error") {
310+
throw new Error(result.errorMessage);
311+
}
310312
},
311313
onSuccess: () => {
312314
router.push("/team");
313315
},
314316
});
315317

316318
function handleDelete() {
317-
const promise = deleteTeam.mutateAsync();
319+
const promise = deleteTeamAndRedirect.mutateAsync();
318320
toast.promise(promise, {
319-
success: "Team deleted successfully",
321+
success: "Team deleted",
320322
error: "Failed to delete team",
321323
});
322324
}
323325

324-
if (props.enabled) {
326+
if (props.canDelete) {
325327
return (
326328
<DangerSettingCard
327329
title={title}
328330
description={description}
329331
buttonLabel={title}
330332
buttonOnClick={handleDelete}
331-
isPending={deleteTeam.isPending}
333+
isPending={deleteTeamAndRedirect.isPending}
332334
confirmationDialog={{
333335
title: `Are you sure you want to delete team "${props.teamName}" ?`,
334336
description: description,

0 commit comments

Comments
 (0)