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
+
+
+
+
+
+
+ );
+}
+
+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 */}
-
-
-
+
+
+
);
}
function RoleSelector(props: {
disabled?: boolean;
+ value: TeamAccountRole;
+ onChange: (v: TeamAccountRole) => void;
}) {
const roles: TeamAccountRole[] = ["OWNER", "MEMBER"];
- const [role, setRole] = useState
("MEMBER");
return (
);
- } else {
- topSection = (
-
-
-
-
-
-
-
-
- );
}
return (
- Team Members
-
-
-
-
+
{/* Card */}
@@ -127,6 +110,14 @@ export function ManageMembersSection(props: {
{
+ setDeletedMembersIds([
+ ...deletedMembersIds,
+ member.accountId,
+ ]);
+ }}
/>
);
@@ -148,27 +139,30 @@ export function ManageMembersSection(props: {
function MemberRow(props: {
member: TeamMember;
userHasEditPermission: boolean;
+ client: ThirdwebClient;
+ deleteMember: (memberId: string) => Promise;
+ onMemberDeleted: () => void;
}) {
return (
-
+
- {/* Checkbox */}
-
- {/* PFP */}
-
-
-
-
- {props.member.account.name}
-
-
- {props.member.account.email}
+
+
+ {props.member.account.name || props.member.account.email}
+
+ {props.member.account.name && (
+
+ {props.member.account.email}
+
+ )}
@@ -177,119 +171,101 @@ function MemberRow(props: {
{props.member.role.toLowerCase()}
-
-
-
- );
-}
-
-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 */}
-
-
-
-
-
-
-
-
+ {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 (
-
+
+ >
);
}
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;
}