diff --git a/apps/dashboard/src/@/api/team-members.ts b/apps/dashboard/src/@/api/team-members.ts index afe19b9316b..30bdccba4aa 100644 --- a/apps/dashboard/src/@/api/team-members.ts +++ b/apps/dashboard/src/@/api/team-members.ts @@ -47,3 +47,26 @@ export async function getMembers(teamSlug: string) { return undefined; } + +export async function getMemberById(teamSlug: string, memberId: string) { + const token = await getAuthToken(); + + if (!token) { + return undefined; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${teamSlug}/members/${memberId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (res.ok) { + return (await res.json())?.result as TeamMember; + } + + return undefined; +} diff --git a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx index fedd7461d44..6b76e427854 100644 --- a/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx +++ b/apps/dashboard/src/@/components/blocks/DangerSettingCard.tsx @@ -6,7 +6,6 @@ import { DialogClose, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, @@ -17,13 +16,14 @@ export function DangerSettingCard(props: { title: string; className?: string; footerClassName?: string; - description: string; + description: React.ReactNode; buttonLabel: string; buttonOnClick: () => void; + isDisabled?: boolean; isPending: boolean; confirmationDialog: { title: string; - description: string; + description: React.ReactNode; }; children?: React.ReactNode; }) { @@ -55,7 +55,7 @@ export function DangerSettingCard(props: { @@ -90,7 +92,7 @@ export function DangerSettingCard(props: { {props.isPending && } {props.buttonLabel} - + diff --git a/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx b/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx index e64bbcdee63..11c7c50b852 100644 --- a/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx +++ b/apps/dashboard/src/app/account/overview/AccountTeamsUI.tsx @@ -108,7 +108,7 @@ function TeamRow(props: {
diff --git a/apps/dashboard/src/app/account/page.tsx b/apps/dashboard/src/app/account/page.tsx index 8c7543c0c5b..01653f1e826 100644 --- a/apps/dashboard/src/app/account/page.tsx +++ b/apps/dashboard/src/app/account/page.tsx @@ -1,6 +1,7 @@ import { getTeams } from "@/api/team"; -import { getMembers } from "@/api/team-members"; +import { getMemberById } from "@/api/team-members"; import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { notFound } from "next/navigation"; import { getAuthToken } from "../api/lib/getAuthToken"; import { loginRedirect } from "../login/loginRedirect"; import { AccountTeamsUI } from "./overview/AccountTeamsUI"; @@ -17,28 +18,20 @@ export default async function Page() { loginRedirect("/account"); } - const teamsWithRole = ( - await Promise.all( - teams.map(async (team) => { - const members = await getMembers(team.slug); - if (!members) { - return { - team, - role: "MEMBER" as const, - }; - } + const teamsWithRole = await Promise.all( + teams.map(async (team) => { + const member = await getMemberById(team.slug, account.id); - const accountMemberInfo = members.find( - (m) => m.accountId === account.id, - ); + if (!member) { + notFound(); + } - return { - team, - role: accountMemberInfo?.role || "MEMBER", - }; - }), - ) - ).filter((x) => !!x); + return { + team, + role: member.role, + }; + }), + ); return (
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx index 1920edab966..a75e9cec2dd 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx @@ -1,11 +1,11 @@ +import { getThirdwebClient } from "@/constants/thirdweb.server"; import type { Meta, StoryObj } from "@storybook/react"; import { Toaster } from "sonner"; -import { projectStub } from "../../../../../stories/stubs"; -import { mobileViewport } from "../../../../../stories/utils"; +import { projectStub, teamStub } from "../../../../../stories/stubs"; import { ProjectGeneralSettingsPageUI } from "./ProjectGeneralSettingsPage"; const meta = { - title: "Project/Settings/General", + title: "Project/Settings", component: Story, parameters: { nextjs: { @@ -17,32 +17,56 @@ const meta = { export default meta; type Story = StoryObj; -export const Desktop: Story = { - args: {}, +export const OwnerAccount: Story = { + args: { + isOwnerAccount: true, + }, }; -export const Mobile: Story = { - args: {}, - parameters: { - viewport: mobileViewport("iphone14"), +export const MemberAccount: Story = { + args: { + isOwnerAccount: false, }, }; -function Story() { +function Story(props: { + isOwnerAccount: boolean; +}) { + const currentTeam = teamStub("currentTeam", "free"); return (
{ + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("transferProject", newTeam); + }} + client={getThirdwebClient()} + teamsWithRole={[ + { + role: props.isOwnerAccount ? "OWNER" : "MEMBER", + team: currentTeam, + }, + { + role: "OWNER", + team: teamStub("bar", "growth"), + }, + { + role: "MEMBER", + team: teamStub("baz", "starter"), + }, + ]} updateProject={async (params) => { await new Promise((resolve) => setTimeout(resolve, 1000)); console.log("updateProject", params); - return projectStub("foo", "team-1"); + return projectStub("foo", "currentTeam"); }} deleteProject={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); console.log("deleteProject"); }} - project={projectStub("foo", "team-1")} - teamSlug="foo" + project={projectStub("foo", currentTeam.id)} + teamSlug={currentTeam.slug} onKeyUpdated={undefined} rotateSecretKey={async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx index 0ed74c41bc1..7b98d097775 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx @@ -1,5 +1,7 @@ "use client"; import type { Project } from "@/api/projects"; +import type { Team } from "@/api/team"; +import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar"; import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; import { SettingsCard } from "@/components/blocks/SettingsCard"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; @@ -18,6 +20,13 @@ import { import { Form } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ToolTipLabel } from "@/components/ui/tooltip"; @@ -43,16 +52,19 @@ import { CircleAlertIcon, ExternalLinkIcon, RefreshCcwIcon, + TriangleAlertIcon, } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { type FieldArrayWithId, useFieldArray } from "react-hook-form"; import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; import { RE_BUNDLE_ID } from "utils/regex"; import { joinWithComma, toArrFromList } from "utils/string"; import { validStrList } from "utils/validations"; import { z } from "zod"; +import { apiServerProxy } from "../../../../../@/actions/proxies"; import { HIDDEN_SERVICES, projectDomainsSchema, @@ -85,15 +97,26 @@ type ProjectSettingPaths = { afterDeleteRedirectTo: string; }; +type TeamWithRole = { + role: "MEMBER" | "OWNER"; + team: Team; +}; + export function ProjectGeneralSettingsPage(props: { project: Project; teamSlug: string; + teamId: string; showNebulaSettings: boolean; + teamsWithRole: TeamWithRole[]; + client: ThirdwebClient; + isOwnerAccount: boolean; }) { const router = useDashboardRouter(); return ( { @@ -118,6 +141,28 @@ export function ProjectGeneralSettingsPage(props: { rotateSecretKey={async () => { return rotateSecretKeyClient(props.project.id); }} + teamsWithRole={props.teamsWithRole} + transferProject={async (newTeam) => { + const res = await apiServerProxy({ + pathname: `/v1/teams/${props.teamId}/projects/${props.project.id}/transfer`, + method: "POST", + body: JSON.stringify({ + destinationTeamId: newTeam.id, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + console.error(res.error); + throw new Error(res.error); + } + + // Can't open new project in new team or new team landing pagae because it takes a while for the transfer and it doesn't show up in new team immediately + // so the safe option is to just redirect to the current team landing page + router.replace(`/team/${props.teamSlug}`); + }} /> ); } @@ -135,6 +180,10 @@ export function ProjectGeneralSettingsPageUI(props: { showNebulaSettings: boolean; rotateSecretKey: RotateSecretKey; teamSlug: string; + teamsWithRole: TeamWithRole[]; + client: ThirdwebClient; + transferProject: (newTeam: Team) => Promise; + isOwnerAccount: boolean; }) { const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`; @@ -296,6 +345,15 @@ export function ProjectGeneralSettingsPageUI(props: { showNebulaSettings={props.showNebulaSettings} /> + + ); } + +function TransferProject(props: { + projectName: string; + teamsWithRole: { role: "MEMBER" | "OWNER"; team: Team }[]; + currentTeamId: string; + transferProject: (newTeam: Team) => Promise; + client: ThirdwebClient; + isOwnerAccount: boolean; +}) { + const [selectedTeamId, setSelectedTeamId] = useState(""); + const transferProject = useMutation({ + mutationFn: props.transferProject, + }); + + const selectedTeamWithRole = props.teamsWithRole.find( + ({ team }) => team.id === selectedTeamId, + ); + const hasOwnerRole = selectedTeamWithRole?.role === "OWNER"; + + const isDisabled = + !selectedTeamWithRole || + !hasOwnerRole || + selectedTeamId === props.currentTeamId || + !props.isOwnerAccount; + + console.log({ selectedTeam: selectedTeamWithRole, isDisabled }); + + const handleTransfer = () => { + if (!hasOwnerRole) { + return; + } + + const promise = transferProject.mutateAsync(selectedTeamWithRole.team); + toast.promise(promise, { + success: "Project transferred successfully", + error: "Failed to transfer project", + }); + }; + + return ( + + + Are you sure you want to transfer this project to{" "} + + {selectedTeamWithRole?.team.name} + {" "} + team? + + + + + Current team will lose access to this project and all associated + resources + + + + + Selected team will take over ownership and billing responsibility + for this project and all associated resources + + + ), + }} + description={<>Transfer this project to another team} + isPending={transferProject.isPending} + title="Transfer Project" + > +
+
+ +
+ + {!props.isOwnerAccount && ( +

+ You do not have permission to transfer this project, You are not an + owner of the current team +

+ )} + + {selectedTeamId === props.currentTeamId && ( +

+ Project is already in the selected team +

+ )} + + {selectedTeamId && !hasOwnerRole && ( +

+ You do not have permission to transfer this project, You are not an + owner of the selected team +

+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx index a577312c9ae..744075c97ac 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/page.tsx @@ -1,19 +1,33 @@ import { getProject } from "@/api/projects"; -import { getTeamBySlug } from "@/api/team"; -import { redirect } from "next/navigation"; +import { getTeams } from "@/api/team"; +import { getMemberById } from "@/api/team-members"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { notFound, redirect } from "next/navigation"; +import { getValidAccount } from "../../../../account/settings/getAccount"; +import { getAuthToken } from "../../../../api/lib/getAuthToken"; +import { loginRedirect } from "../../../../login/loginRedirect"; import { ProjectGeneralSettingsPage } from "./ProjectGeneralSettingsPage"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const { team_slug, project_slug } = await props.params; + const pagePath = `/team/${team_slug}/${project_slug}/settings`; - const [team, project] = await Promise.all([ - getTeamBySlug(team_slug), + const [authToken, teams, project, account] = await Promise.all([ + getAuthToken(), + getTeams(), getProject(team_slug, project_slug), + getValidAccount("/account"), ]); - if (!team) { + if (!teams || !authToken) { + loginRedirect(pagePath); + } + + const currentTeam = teams.find((t) => t.slug === team_slug); + + if (!currentTeam) { redirect("/team"); } @@ -21,11 +35,36 @@ export default async function Page(props: { redirect(`/team/${team_slug}`); } + const teamsWithRole = await Promise.all( + teams.map(async (team) => { + const member = await getMemberById(team.slug, account.id); + + if (!member) { + notFound(); + } + + return { + team, + role: member.role, + }; + }), + ); + + const currentTeamWithRole = teamsWithRole.find( + (teamWithRole) => teamWithRole.team.id === currentTeam.id, + ); + + const isOwnerAccount = currentTeamWithRole?.role === "OWNER"; + return ( ); }