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({