From 7775e7c14eafbc465e638aa678c9cf7d10925b06 Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 21 Feb 2025 07:28:35 +0000 Subject: [PATCH] [TOOL-3473] Add Team Member invites (#6301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on enhancing the team management features in the dashboard, including invite management, member roles, and UI improvements for better user experience. ### Detailed summary - Added `skipShowingPlans` prop in `LoginPage`. - Updated `AnnouncementBanner` to include new layout segments. - Enhanced `TeamMember` type with new fields. - Introduced `DotsBackgroundPattern` component for UI. - Implemented `service_getTeamBySlug` API function. - Added `leaveTeam` functionality in various components. - Improved onboarding logic to skip plans. - Created `JoinTeamPage` with invite handling. - Added invite management features in `ManageInvitesSection`. - Enhanced member management with delete functionality. - Updated UI components for better accessibility and usability. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/src/@/actions/acceptInvite.ts | 53 +++ .../dashboard/src/@/actions/sendTeamInvite.ts | 68 ++++ apps/dashboard/src/@/api/team-invites.ts | 46 +++ apps/dashboard/src/@/api/team-members.ts | 9 +- apps/dashboard/src/@/api/team.ts | 16 +- .../@/components/ui/background-patterns.tsx | 20 + .../[invite_id]/JoinTeamPage.stories.tsx | 42 +++ .../[team_slug]/[invite_id]/JoinTeamPage.tsx | 142 +++++++ .../team/[team_slug]/[invite_id]/page.tsx | 29 ++ apps/dashboard/src/app/login/LoginPage.tsx | 1 + .../onboarding/on-boarding-ui.client.tsx | 10 +- .../general/GeneralSettingsPage.stories.tsx | 11 +- .../general/TeamGeneralSettingsPage.tsx | 14 + .../general/TeamGeneralSettingsPageUI.tsx | 46 +-- .../members/InviteSection.stories.tsx | 174 +++++++++ .../~/settings/members/InviteSection.tsx | 355 ++++++++++++++---- .../members/ManageInvitesSection.stories.tsx | 103 +++++ .../settings/members/ManageInvitesSection.tsx | 279 ++++++++++++++ .../settings/members/ManageMembersSection.tsx | 306 +++++++-------- .../TeamMembersSettingsPage.stories.tsx | 122 +++--- .../members/TeamMembersSettingsPage.tsx | 115 +++++- .../(team)/~/settings/members/_common.tsx | 117 ++++++ .../(team)/~/settings/members/page.tsx | 23 +- .../[team_slug]/(team)/~/settings/page.tsx | 17 +- .../components/notices/AnnouncementBanner.tsx | 3 +- 25 files changed, 1742 insertions(+), 379 deletions(-) create mode 100644 apps/dashboard/src/@/actions/acceptInvite.ts create mode 100644 apps/dashboard/src/@/actions/sendTeamInvite.ts create mode 100644 apps/dashboard/src/@/api/team-invites.ts create mode 100644 apps/dashboard/src/@/components/ui/background-patterns.tsx create mode 100644 apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.stories.tsx create mode 100644 apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx create mode 100644 apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/page.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/ManageInvitesSection.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/ManageInvitesSection.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/_common.tsx diff --git a/apps/dashboard/src/@/actions/acceptInvite.ts b/apps/dashboard/src/@/actions/acceptInvite.ts new file mode 100644 index 00000000000..aa96cf1fe33 --- /dev/null +++ b/apps/dashboard/src/@/actions/acceptInvite.ts @@ -0,0 +1,53 @@ +"use server"; + +import { getAuthToken } from "../../app/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export async function acceptInvite(options: { + teamId: string; + inviteId: string; +}) { + const token = await getAuthToken(); + + if (!token) { + return { + ok: false, + errorMessage: "You are not authorized to perform this action", + }; + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }, + ); + + if (!res.ok) { + let errorMessage = "Failed to accept invite"; + try { + const result = (await res.json()) as { + error: { + code: string; + message: string; + statusCode: number; + }; + }; + errorMessage = result.error.message; + } catch {} + + return { + ok: false, + errorMessage, + }; + } + + return { + ok: true, + }; +} diff --git a/apps/dashboard/src/@/actions/sendTeamInvite.ts b/apps/dashboard/src/@/actions/sendTeamInvite.ts new file mode 100644 index 00000000000..5f5163d05be --- /dev/null +++ b/apps/dashboard/src/@/actions/sendTeamInvite.ts @@ -0,0 +1,68 @@ +"use server"; + +import { getAuthToken } from "../../app/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export async function sendTeamInvites(options: { + teamId: string; + invites: Array<{ email: string; role: "OWNER" | "MEMBER" }>; +}): Promise< + | { + ok: true; + results: Array<"fulfilled" | "rejected">; + } + | { + ok: false; + errorMessage: string; + } +> { + const token = await getAuthToken(); + + if (!token) { + return { + ok: false, + errorMessage: "You are not authorized to perform this action", + }; + } + + const results = await Promise.allSettled( + options.invites.map((invite) => sendInvite(options.teamId, invite, token)), + ); + + return { + ok: true, + results: results.map((x) => x.status), + }; +} + +async function sendInvite( + teamId: string, + invite: { email: string; role: "OWNER" | "MEMBER" }, + token: string, +) { + const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamId}/invites`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + inviteEmail: invite.email, + inviteRole: invite.role, + }), + }); + + if (!res.ok) { + const errorMessage = await res.text(); + return { + email: invite.email, + ok: false, + errorMessage, + }; + } + + return { + email: invite.email, + ok: true, + }; +} diff --git a/apps/dashboard/src/@/api/team-invites.ts b/apps/dashboard/src/@/api/team-invites.ts new file mode 100644 index 00000000000..1d414954510 --- /dev/null +++ b/apps/dashboard/src/@/api/team-invites.ts @@ -0,0 +1,46 @@ +import { getAuthToken } from "../../app/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export type TeamInvite = { + id: string; + teamId: string; + email: string; + role: "OWNER" | "MEMBER"; + createdAt: string; + status: "pending" | "accepted" | "expired"; + expiresAt: string; +}; + +export async function getTeamInvites( + teamId: string, + options: { + count: number; + start: number; + }, +) { + const authToken = await getAuthToken(); + + if (!authToken) { + throw new Error("Unauthorized"); + } + + const res = await fetch( + `${API_SERVER_URL}/v1/teams/${teamId}/invites?skip=${options.start}&take=${options.count}`, + { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }, + ); + + if (!res.ok) { + const errorMessage = await res.text(); + throw new Error(errorMessage); + } + + const json = (await res.json()) as { + result: TeamInvite[]; + }; + + return json.result; +} diff --git a/apps/dashboard/src/@/api/team-members.ts b/apps/dashboard/src/@/api/team-members.ts index bc754f39fca..afe19b9316b 100644 --- a/apps/dashboard/src/@/api/team-members.ts +++ b/apps/dashboard/src/@/api/team-members.ts @@ -12,15 +12,16 @@ export type TeamAccountRole = export type TeamMember = { account: { + creatorWalletAddress: string; name: string; email: string | null; + image: string | null; }; -} & { - deletedAt: Date | null; + deletedAt: string | null; accountId: string; teamId: string; - createdAt: Date; - updatedAt: Date; + createdAt: string; + updatedAt: string; role: TeamAccountRole; }; diff --git a/apps/dashboard/src/@/api/team.ts b/apps/dashboard/src/@/api/team.ts index efdb350da83..ef51d5ca776 100644 --- a/apps/dashboard/src/@/api/team.ts +++ b/apps/dashboard/src/@/api/team.ts @@ -1,5 +1,5 @@ import "server-only"; -import { API_SERVER_URL } from "@/constants/env"; +import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env"; import type { TeamResponse } from "@thirdweb-dev/service-utils"; import { getAuthToken } from "../../app/api/lib/getAuthToken"; @@ -22,6 +22,20 @@ export async function getTeamBySlug(slug: string) { return null; } +export async function service_getTeamBySlug(slug: string) { + const teamRes = await fetch(`${API_SERVER_URL}/v1/teams/${slug}`, { + headers: { + "x-service-api-key": THIRDWEB_API_SECRET, + }, + }); + + if (teamRes.ok) { + return (await teamRes.json())?.result as Team; + } + + return null; +} + export function getTeamById(id: string) { return getTeamBySlug(id); } diff --git a/apps/dashboard/src/@/components/ui/background-patterns.tsx b/apps/dashboard/src/@/components/ui/background-patterns.tsx new file mode 100644 index 00000000000..664031cb7a3 --- /dev/null +++ b/apps/dashboard/src/@/components/ui/background-patterns.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; + +export function DotsBackgroundPattern(props: { + className?: string; +}) { + return ( +
+ ); +} diff --git a/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.stories.tsx b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.stories.tsx new file mode 100644 index 00000000000..261c230dbe9 --- /dev/null +++ b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.stories.tsx @@ -0,0 +1,42 @@ +import { Toaster } from "@/components/ui/sonner"; +import type { Meta, StoryObj } from "@storybook/react"; +import { mobileViewport } from "../../../../../stories/utils"; +import { JoinTeamPageUI } from "./JoinTeamPage"; + +const meta = { + title: "Team/Join Team", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Story() { + return ( +
+ { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} + /> + +
+ ); +} diff --git a/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx new file mode 100644 index 00000000000..d6a875c4b3c --- /dev/null +++ b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/JoinTeamPage.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { acceptInvite } from "@/actions/acceptInvite"; +import type { Team } from "@/api/team"; +import { ToggleThemeButton } from "@/components/color-mode-toggle"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { DotsBackgroundPattern } from "@/components/ui/background-patterns"; +import { Button } from "@/components/ui/button"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { useMutation } from "@tanstack/react-query"; +import { CheckIcon, UsersIcon } from "lucide-react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { ThirdwebMiniLogo } from "../../../../components/ThirdwebMiniLogo"; + +export function JoinTeamPage(props: { + team: Team; + inviteId: string; +}) { + const router = useDashboardRouter(); + return ( + { + const res = await acceptInvite({ + inviteId: props.inviteId, + teamId: props.team.id, + }); + + if (!res.ok) { + console.error(res.errorMessage); + throw new Error(res.errorMessage); + } + + router.replace(`/team/${props.team.slug}`); + }} + /> + ); +} + +export function JoinTeamPageUI(props: { + teamName: string; + invite: () => Promise; +}) { + return ( +
+
+ +
+
+ +
+
+ + +
+ ); +} + +function Header() { + return ( +
+
+
+ + + thirdweb + +
+ +
+
+ + Support + +
+ +
+
+
+ ); +} + +function AcceptInviteCardUI(props: { + teamName: string; + invite: () => Promise; +}) { + const invite = useMutation({ + mutationFn: props.invite, + }); + return ( +
+
+
+ +
+ +

+ Join your team on thirdweb +

+

+ You have been invited to join team{" "} + {props.teamName}{" "} +

+ +

+ Accepting this invite will add you to the team and give you access to + the team's resources +

+
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/page.tsx b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/page.tsx new file mode 100644 index 00000000000..012a5d0fb99 --- /dev/null +++ b/apps/dashboard/src/app/join/team/[team_slug]/[invite_id]/page.tsx @@ -0,0 +1,29 @@ +import { getTeamBySlug, service_getTeamBySlug } from "@/api/team"; +import { notFound, redirect } from "next/navigation"; +import { getValidAccount } from "../../../../account/settings/getAccount"; +import { JoinTeamPage } from "./JoinTeamPage"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; invite_id: string }>; +}) { + const { team_slug, invite_id } = await props.params; + + // ensure the user is logged in + onboarded + await getValidAccount(`/join/team/${team_slug}/${invite_id}`); + + const [userTeam, inviteTeam] = await Promise.all([ + getTeamBySlug(team_slug), + service_getTeamBySlug(team_slug), + ]); + + // if the user is already a member of the team, redirect to the team + if (userTeam) { + redirect(`/team/${team_slug}`); + } + + if (!inviteTeam) { + notFound(); + } + + return ; +} diff --git a/apps/dashboard/src/app/login/LoginPage.tsx b/apps/dashboard/src/app/login/LoginPage.tsx index d01be457eb4..f3ab1479128 100644 --- a/apps/dashboard/src/app/login/LoginPage.tsx +++ b/apps/dashboard/src/app/login/LoginPage.tsx @@ -202,6 +202,7 @@ function PageContent(props: { onLogout={() => { setScreen({ id: "login" }); }} + skipShowingPlans={props.redirectPath.startsWith("/join/team")} /> ); diff --git a/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx b/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx index 85467d12253..54b72f7b2bd 100644 --- a/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx +++ b/apps/dashboard/src/app/login/onboarding/on-boarding-ui.client.tsx @@ -25,6 +25,7 @@ function OnboardingUI(props: { // path to redirect from stripe redirectPath: string; redirectToCheckout: RedirectBillingCheckoutAction; + skipShowingPlans: boolean; }) { const { account } = props; const [screen, setScreen] = useState({ id: "onboarding" }); @@ -119,7 +120,12 @@ function OnboardingUI(props: { if (account.onboardSkipped) { props.onComplete(); } else { - setScreen({ id: "plan", team: res.team }); + if (props.skipShowingPlans) { + props.onComplete(); + skipOnboarding(); + } else { + setScreen({ id: "plan", team: res.team }); + } } } }} @@ -137,8 +143,8 @@ function OnboardingUI(props: { redirectPath={props.redirectPath} teamSlug={screen.team.slug} skipPlan={async () => { - await skipOnboarding().catch(() => {}); props.onComplete(); + skipOnboarding(); }} canTrialGrowth={true} redirectToCheckout={props.redirectToCheckout} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx index 2c6ea9c6747..f19dfc8d3cb 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx @@ -48,6 +48,9 @@ function Story() { await new Promise((resolve) => setTimeout(resolve, 1000)); }} client={getThirdwebClient()} + leaveTeam={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} /> @@ -61,8 +64,12 @@ function ComponentVariants() {

Component variations

- - + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} + />
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx index 8316867373c..a44bd96b093 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPage.tsx @@ -1,5 +1,6 @@ "use client"; +import { apiServerProxy } from "@/actions/proxies"; import type { Team } from "@/api/team"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import type { ThirdwebClient } from "thirdweb"; @@ -10,6 +11,7 @@ import { updateTeam } from "./updateTeam"; export function TeamGeneralSettingsPage(props: { team: Team; client: ThirdwebClient; + accountId: string; }) { const router = useDashboardRouter(); @@ -30,6 +32,18 @@ export function TeamGeneralSettingsPage(props: { router.refresh(); } }} + leaveTeam={async () => { + const res = await apiServerProxy({ + pathname: `/v1/teams/${props.team.id}/members/${props.accountId}`, + method: "DELETE", + }); + + if (!res.ok) { + throw new Error(res.error); + } + + router.replace("/team"); + }} updateTeamImage={async (file) => { let uri: string | undefined = undefined; diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx index e4a6f3cfaa1..a5747083397 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/TeamGeneralSettingsPageUI.tsx @@ -20,6 +20,7 @@ export function TeamGeneralSettingsPageUI(props: { updateTeamImage: (file: File | undefined) => Promise; updateTeamField: UpdateTeamField; client: ThirdwebClient; + leaveTeam: () => Promise; }) { const hasPermissionToDelete = false; // TODO return ( @@ -38,7 +39,7 @@ export function TeamGeneralSettingsPageUI(props: { client={props.client} /> - + Promise; }) { const title = "Leave Team"; const description = "Revoke your access to this Team. Any resources you've added to the Team will remain."; - // TODO const leaveTeam = useMutation({ - mutationFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)); - console.log("Deleting team"); - throw new Error("Not implemented"); - }, + mutationFn: props.leaveTeam, }); function handleLeave() { @@ -262,32 +258,18 @@ export function LeaveTeamCard(props: { }); } - if (props.enabled) { - return ( - - ); - } - return ( - ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx new file mode 100644 index 00000000000..71cfc9c31fe --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.stories.tsx @@ -0,0 +1,174 @@ +import type { TeamAccountRole } from "@/api/team-members"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import {} from "@/components/ui/select"; +import { Toaster } from "@/components/ui/sonner"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { teamStub } from "../../../../../../../stories/stubs"; +import { mobileViewport } from "../../../../../../../stories/utils"; +import { InviteSection } from "./InviteSection"; + +const meta = { + title: "Team/Settings/Members/InviteSection", + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +const TEAM_CONFIGS = [ + { id: "free", label: "Free Team", team: teamStub("foo", "free") }, + { id: "starter", label: "Starter Team", team: teamStub("foo", "starter") }, + { id: "growth", label: "Growth Team", team: teamStub("bazz", "growth") }, + { id: "pro", label: "Pro Team", team: teamStub("bar", "pro") }, +] as const; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +type InviteParams = Array<{ email: string; role: TeamAccountRole }>; + +const INVITE_HANDLERS = { + success: async (params: InviteParams) => { + await sleep(500); + return { results: params.map(() => "fulfilled" as const) }; + }, + failure: async (params: InviteParams) => { + await sleep(500); + return { results: params.map(() => "rejected" as const) }; + }, + mixed: async (params: InviteParams) => { + await sleep(500); + return { + results: params.map((_, index) => + index % 2 === 0 ? ("fulfilled" as const) : ("rejected" as const), + ), + }; + }, +} as const; + +function RadioOption({ + id, + label, + value, +}: { id: string; label: string; value: string }) { + return ( +
+ + +
+ ); +} + +function Story() { + const [selectedTeam, setSelectedTeam] = useState({ + id: "free", + team: TEAM_CONFIGS[0].team, + }); + const [hasEditPermission, setHasEditPermission] = useState("true"); + const [inviteResult, setInviteResult] = + useState("success"); + + const showPermissionControls = + selectedTeam.id !== "free" && selectedTeam.id !== "pro"; + const showInviteControls = + showPermissionControls && hasEditPermission === "true"; + + return ( +
+
+
+

Team Type

+ { + const config = TEAM_CONFIGS.find(({ id }) => id === value); + if (config) setSelectedTeam({ id: value, team: config.team }); + }} + className="flex gap-4" + > + {TEAM_CONFIGS.map(({ id, label }) => ( + + ))} + +
+ + {showPermissionControls && ( +
+

Edit Permission

+ + + + +
+ )} + + {showInviteControls && ( +
+

Invite Result

+ { + setInviteResult(value as keyof typeof INVITE_HANDLERS); + }} + className="flex gap-4" + > + + + + +
+ )} +
+ +
+ +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx index 26321f74543..2b1e3c4cae6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/InviteSection.tsx @@ -1,10 +1,17 @@ "use client"; - import type { Team } from "@/api/team"; import type { TeamAccountRole } from "@/api/team-members"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -12,26 +19,76 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; -import { ExternalLinkIcon, LinkIcon, UserPlus } from "lucide-react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { ExternalLinkIcon, PlusIcon, Trash2Icon, UserPlus } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; import { getValidTeamPlan } from "../../../../../components/TeamHeader/getValidTeamPlan"; +const inviteFormSchema = z.object({ + invites: z + .array( + z.object({ + email: z.string().email("Invalid email address"), + role: z.enum(["OWNER", "MEMBER"] as const), + }), + ) + .min(1, "No invites added"), +}); + +type InviteFormValues = z.infer; + export function InviteSection(props: { team: Team; userHasEditPermission: boolean; + inviteTeamMembers: ( + params: Array<{ + email: string; + role: TeamAccountRole; + }>, + ) => Promise<{ + results: Array<"fulfilled" | "rejected">; + }>; }) { const teamPlan = getValidTeamPlan(props.team); let bottomSection: React.ReactNode = null; - const inviteEnabled = false; // teamPlan !== "free" && props.userHasEditPermission; + const maxAllowedInvitesAtOnce = 10; + const inviteEnabled = + (teamPlan === "starter" || teamPlan === "growth") && + props.userHasEditPermission; + + const form = useForm({ + resolver: zodResolver(inviteFormSchema), + defaultValues: { + invites: [ + { + email: "", + role: "MEMBER", + }, + ], + }, + }); + + const sendInvites = useMutation({ + mutationFn: async (data: InviteFormValues) => { + const res = await props.inviteTeamMembers(data.invites); + + return { + inviteStatuses: res.results, + }; + }, + }); if (teamPlan === "free") { bottomSection = (

- This feature is not available on the Free Plan.{" "} + This feature is not available on the {teamPlan} plan.{" "} +

+ {teamPlan === "pro" && ( +

+ Team invites are not enabled on your plan.{" "} + + Reach out to sales + +

+ )} + + {(teamPlan === "starter" || teamPlan === "growth") && ( +

+ Team members are billed according to your plan.{" "} + + View pricing + +

+ )} +
); } + async function onSubmit(data: InviteFormValues) { + if (!inviteEnabled) return; + sendInvites.mutate(data, { + onSuccess(data) { + const inviteStatuses = data.inviteStatuses; + + const failedInvites = inviteStatuses.filter((r) => r === "rejected"); + const inviteOrInvites = + data.inviteStatuses.length > 1 ? "invites" : "invite"; + + if (failedInvites.length > 0) { + // all invites failed + if (failedInvites.length === data.inviteStatuses.length) { + toast.error(`Failed to send ${inviteOrInvites}`); + } + // some invites failed + else { + toast.error( + `Failed to send ${failedInvites.length} of ${data.inviteStatuses.length} ${inviteOrInvites}`, + ); + } + } + + // all invites succeeded + else { + toast.success( + `Successfully sent ${data.inviteStatuses.length === 1 ? "" : data.inviteStatuses.length} ${inviteOrInvites}`, + ); + } + }, + }); + } + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // when form updates - reset mutation result + const subscription = form.watch(() => { + sendInvites.reset(); + }); + + return () => subscription.unsubscribe(); + }, [form.watch, sendInvites.reset]); + return ( -
-

Invite

- - {/* Card */} -
- {/* Invite via Link */} -
-
-

- Invite new members via email or link -

- - -
-
- -
- -
- - {/* Invite via Email Send */} -
-
-
- - +
+ +
+
+
+

Invite

+ +

+ Invite new members to your team by email +

-
-
+ {bottomSection}
-
- - {bottomSection} -
-
+ + + ); } function RoleSelector(props: { disabled?: boolean; + value: TeamAccountRole; + onChange: (v: TeamAccountRole) => void; }) { const roles: TeamAccountRole[] = ["OWNER", "MEMBER"]; - const [role, setRole] = useState("MEMBER"); return ( -
- -
- - + {props.userHasEditPermission && ( + + )}
); } -type MemberSortId = "date" | "a-z" | "z-a"; - -function SortMembersBy(props: { - disabled?: boolean; - setSortBy: (sortBy: MemberSortId) => void; - sortBy: MemberSortId; +function ManageMemberButton(props: { + member: TeamMember; + userHasEditPermission: boolean; + deleteMember: (memberId: string) => Promise; + onMemberDeleted: () => void; }) { - const { sortBy, setSortBy } = props; - const valueToLabel: Record = { - date: "Date", - "a-z": "Name (A-Z)", - "z-a": "Name (Z-A)", - }; + const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const sortByIds: MemberSortId[] = ["date", "a-z", "z-a"]; + const deleteMutation = useMutation({ + mutationFn: () => props.deleteMember(props.member.accountId), + }); return ( - - ); -} - -function RoleSelector(props: { - disabled?: boolean; - role: RoleFilterValue; - setRole: (role: RoleFilterValue) => void; -}) { - const { role, setRole } = props; - const roles: RoleFilterValue[] = ["OWNER", "MEMBER", "ALL ROLES"]; + <> + + + + + + setShowDeleteDialog(true)} + > + Remove Member + + + - return ( - + + + + Remove Member + + Are you sure you want to remove{" "} + + {props.member.account.name || "this member"} + {" "} + from the team? + + +
+ + +
+
+
+ ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.stories.tsx index c6b5c3ef156..196eec63d92 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.stories.tsx @@ -1,17 +1,16 @@ import type { TeamAccountRole, TeamMember } from "@/api/team-members"; import { Toaster } from "@/components/ui/sonner"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; import type { Meta, StoryObj } from "@storybook/react"; import { teamStub } from "../../../../../../../stories/stubs"; import { BadgeContainer, mobileViewport, } from "../../../../../../../stories/utils"; -import { InviteSection } from "./InviteSection"; import { ManageMembersSection } from "./ManageMembersSection"; -import { TeamMembersSettingsPage } from "./TeamMembersSettingsPage"; const meta = { - title: "Team/Settings/Members", + title: "Team/Settings/Members/Manage", component: Story, parameters: { nextjs: { @@ -35,11 +34,10 @@ export const Mobile: Story = { }; const freeTeam = teamStub("foo", "free"); -const proTeam = teamStub("bar", "pro"); -const growthTeam = teamStub("bazz", "growth"); function createMemberStub( id: string, + name: string, role: TeamAccountRole, createdHours: number, ): TeamMember { @@ -49,88 +47,74 @@ function createMemberStub( const member: TeamMember = { account: { email: `user-${id}@foo.com`, - name: id, + name: name, + creatorWalletAddress: "0x1234567890123456789012345678901234567890", + image: null, }, accountId: `account-id-${id}`, - createdAt: date, + createdAt: date.toISOString(), deletedAt: null, role: role, teamId: "team-id-foo-bar", - updatedAt: new Date(), + updatedAt: date.toISOString(), }; return member; } const membersStub: TeamMember[] = [ - createMemberStub("first-member", "OWNER", 1), - createMemberStub("third-member", "MEMBER", 3), - createMemberStub("second-member", "OWNER", 2), + createMemberStub("first-member", "First Member", "OWNER", 1), + createMemberStub("third-member", "Third Member", "MEMBER", 3), + createMemberStub("second-member", "Second Member", "OWNER", 2), ]; +const membersStubNoName: TeamMember[] = [ + createMemberStub("first-member", "", "OWNER", 1), + createMemberStub("third-member", "", "MEMBER", 3), + createMemberStub("second-member", "", "OWNER", 2), +]; + +const deleteMemberStub = async (memberId: string) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log("deleted", memberId); +}; + function Story() { return (
- - - +
+ + + + + + + + + + + +
); } - -function CompVariants() { - return ( -
-
-

Invite Variants

- - {/* Invite */} -
- - - - - - - - - - - - - - - -
- -
- -

Team Members Variants

- -
- - - - - - - -
-
-
- ); -} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.tsx index 03690e02885..f04f8444f66 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/TeamMembersSettingsPage.tsx @@ -1,38 +1,121 @@ +"use client"; + +import { apiServerProxy } from "@/actions/proxies"; +import { sendTeamInvites } from "@/actions/sendTeamInvite"; import type { Team } from "@/api/team"; +import type { TeamInvite } from "@/api/team-invites"; import type { TeamMember } from "@/api/team-members"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { AlertCircleIcon } from "lucide-react"; +import { TabButtons } from "@/components/ui/tabs"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; import { InviteSection } from "./InviteSection"; +import { ManageInvitesSection } from "./ManageInvitesSection"; import { ManageMembersSection } from "./ManageMembersSection"; export function TeamMembersSettingsPage(props: { team: Team; userHasEditPermission: boolean; members: TeamMember[]; + client: ThirdwebClient; + teamInvites: TeamInvite[]; }) { + const [manageTab, setManageTab] = useState<"members" | "invites">("members"); + const router = useDashboardRouter(); + return (
- - - - Inviting and Managing Team Members is not available yet - - - This feature will be available in Q1 2025 - - -
+

Members

+

+ Manage team members and invitations +

+ +
{ + const res = await sendTeamInvites({ + teamId: props.team.id, + invites: params, + }); + + if (!res.ok) { + throw new Error(res.errorMessage); + } + + router.refresh(); + + return { + results: res.results, + }; + }} /> +
- setManageTab("members"), + isEnabled: true, + }, + { + isActive: manageTab === "invites", + name: "Pending Invites", + onClick: () => setManageTab("invites"), + isEnabled: true, + }, + ]} /> + +
+ + {manageTab === "members" && ( + { + const res = await apiServerProxy({ + pathname: `/v1/teams/${props.team.id}/members/${memberAccountId}`, + method: "DELETE", + }); + + router.refresh(); + + if (!res.ok) { + throw new Error(res.error); + } + }} + /> + )} + + {manageTab === "invites" && ( + { + const res = await apiServerProxy({ + pathname: `/v1/teams/${props.team.id}/invites/${inviteId}`, + method: "DELETE", + }); + + router.refresh(); + + if (!res.ok) { + throw new Error(res.error); + } + }} + teamInvites={props.teamInvites} + /> + )}
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/_common.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/_common.tsx new file mode 100644 index 00000000000..87ff5be7753 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/_common.tsx @@ -0,0 +1,117 @@ +"use client"; + +import type { TeamAccountRole } from "@/api/team-members"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SearchIcon } from "lucide-react"; + +export type RoleFilterValue = "ALL ROLES" | TeamAccountRole; +export type MemberSortId = "date" | "a-z" | "z-a"; + +export function FiltersSection(props: { + disabled: boolean; + role: RoleFilterValue; + setRole: (role: RoleFilterValue) => void; + setSortBy: (sortBy: MemberSortId) => void; + sortBy: MemberSortId; +}) { + const { role, setRole, setSortBy, sortBy } = props; + return ( +
+ {/* Search */} +
+ + +
+ +
+ + +
+
+ ); +} + +function SortMembersBy(props: { + disabled?: boolean; + setSortBy: (sortBy: MemberSortId) => void; + sortBy: MemberSortId; +}) { + const { sortBy, setSortBy } = props; + const valueToLabel: Record = { + date: "Date", + "a-z": "Name (A-Z)", + "z-a": "Name (Z-A)", + }; + + const sortByIds: MemberSortId[] = ["date", "a-z", "z-a"]; + + return ( + + ); +} + +function RoleSelector(props: { + disabled?: boolean; + role: RoleFilterValue; + setRole: (role: RoleFilterValue) => void; +}) { + const { role, setRole } = props; + const roles: RoleFilterValue[] = ["OWNER", "MEMBER", "ALL ROLES"]; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/page.tsx index 49ef0270da1..c394777aa40 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/members/page.tsx @@ -1,7 +1,10 @@ import { getTeamBySlug } from "@/api/team"; +import { getTeamInvites } from "@/api/team-invites"; import { getMembers } 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 { TeamMembersSettingsPage } from "./TeamMembersSettingsPage"; export default async function Page(props: { @@ -10,13 +13,23 @@ export default async function Page(props: { }>; }) { const params = await props.params; + const pagePath = `/team/${params.team_slug}/~/settings/members`; - const [account, team, members] = await Promise.all([ - getValidAccount(`/team/${params.team_slug}/~/settings/members`), + const [authToken, account, team, members, teamInvites] = await Promise.all([ + getAuthToken(), + getValidAccount(pagePath), getTeamBySlug(params.team_slug), getMembers(params.team_slug), + getTeamInvites(params.team_slug, { + count: 100, + start: 0, + }), ]); + if (!authToken) { + redirect(pagePath); + } + if (!team) { redirect("/team"); } @@ -33,11 +46,17 @@ export default async function Page(props: { notFound(); } + const pendingOrExpiredInvites = teamInvites.filter( + (invite) => invite.status === "pending" || invite.status === "expired", + ); + return ( ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/page.tsx index 78663689bbc..f4a4a2f07d9 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/page.tsx @@ -1,6 +1,7 @@ import { getTeamBySlug } from "@/api/team"; import { getThirdwebClient } from "@/constants/thirdweb.server"; import { notFound } from "next/navigation"; +import { getValidAccount } from "../../../../../account/settings/getAccount"; import { getAuthToken } from "../../../../../api/lib/getAuthToken"; import { TeamGeneralSettingsPage } from "./general/TeamGeneralSettingsPage"; @@ -9,13 +10,23 @@ export default async function Page(props: { team_slug: string; }>; }) { - const team = await getTeamBySlug((await props.params).team_slug); - const token = await getAuthToken(); + const params = await props.params; + + const [team, account, token] = await Promise.all([ + getTeamBySlug(params.team_slug), + getValidAccount(`/team/${params.team_slug}/settings`), + getAuthToken(), + ]); + if (!team || !token) { notFound(); } return ( - + ); } diff --git a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx index 8bedc8dbf56..aaf1e77ad9f 100644 --- a/apps/dashboard/src/components/notices/AnnouncementBanner.tsx +++ b/apps/dashboard/src/components/notices/AnnouncementBanner.tsx @@ -18,7 +18,8 @@ export function AnnouncementBanner(props: { layoutSegment === "/_not-found" || hasDismissedAnnouncement || layoutSegment === "login" || - layoutSegment === "nebula-app" + layoutSegment === "nebula-app" || + layoutSegment === "join" ) { return null; }