Skip to content

Commit 211de11

Browse files
committed
[TOOL-3473] Add Team Member invites
1 parent 724e7d9 commit 211de11

File tree

20 files changed

+747
-189
lines changed

20 files changed

+747
-189
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use server";
2+
3+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
4+
import { API_SERVER_URL } from "../constants/env";
5+
6+
export async function acceptInvite(options: {
7+
teamId: string;
8+
inviteId: string;
9+
}) {
10+
const token = await getAuthToken();
11+
12+
if (!token) {
13+
return {
14+
ok: false,
15+
errorMessage: "You are not authorized to perform this action",
16+
};
17+
}
18+
19+
const res = await fetch(
20+
`${API_SERVER_URL}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`,
21+
{
22+
method: "POST",
23+
headers: {
24+
"Content-Type": "application/json",
25+
Authorization: `Bearer ${token}`,
26+
},
27+
body: JSON.stringify({}),
28+
},
29+
);
30+
31+
if (!res.ok) {
32+
let errorMessage = "Failed to accept invite";
33+
try {
34+
const result = (await res.json()) as {
35+
error: {
36+
code: string;
37+
message: string;
38+
statusCode: number;
39+
};
40+
};
41+
errorMessage = result.error.message;
42+
} catch {}
43+
44+
return {
45+
ok: false,
46+
errorMessage,
47+
};
48+
}
49+
50+
return {
51+
ok: true,
52+
};
53+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"use server";
2+
3+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
4+
import { API_SERVER_URL } from "../constants/env";
5+
6+
export async function sendTeamInvite(options: {
7+
teamId: string;
8+
email: string;
9+
role: "OWNER" | "MEMBER";
10+
}) {
11+
const token = await getAuthToken();
12+
13+
if (!token) {
14+
throw new Error("You are not authorized to perform this action");
15+
}
16+
17+
const res = await fetch(
18+
`${API_SERVER_URL}/v1/teams/${options.teamId}/invites`,
19+
{
20+
method: "POST",
21+
headers: {
22+
Authorization: `Bearer ${token}`,
23+
"Content-Type": "application/json",
24+
},
25+
body: JSON.stringify({
26+
inviteEmail: options.email,
27+
inviteRole: options.role,
28+
}),
29+
},
30+
);
31+
32+
if (!res.ok) {
33+
const errorMessage = await res.text();
34+
35+
return {
36+
ok: false,
37+
errorMessage,
38+
};
39+
}
40+
41+
return {
42+
ok: true,
43+
};
44+
}

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ export type TeamAccountRole =
1212

1313
export type TeamMember = {
1414
account: {
15+
creatorWalletAddress: string;
1516
name: string;
1617
email: string | null;
18+
image: string | null;
1719
};
18-
} & {
19-
deletedAt: Date | null;
20+
deletedAt: string | null;
2021
accountId: string;
2122
teamId: string;
22-
createdAt: Date;
23-
updatedAt: Date;
23+
createdAt: string;
24+
updatedAt: string;
2425
role: TeamAccountRole;
2526
};
2627

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import "server-only";
2-
import { API_SERVER_URL } from "@/constants/env";
2+
import { API_SERVER_URL, THIRDWEB_API_SECRET } from "@/constants/env";
33
import type { TeamResponse } from "@thirdweb-dev/service-utils";
44
import { getAuthToken } from "../../app/api/lib/getAuthToken";
55

@@ -22,6 +22,20 @@ export async function getTeamBySlug(slug: string) {
2222
return null;
2323
}
2424

25+
export async function service_getTeamBySlug(slug: string) {
26+
const teamRes = await fetch(`${API_SERVER_URL}/v1/teams/${slug}`, {
27+
headers: {
28+
"x-service-api-key": THIRDWEB_API_SECRET,
29+
},
30+
});
31+
32+
if (teamRes.ok) {
33+
return (await teamRes.json())?.result as Team;
34+
}
35+
36+
return null;
37+
}
38+
2539
export function getTeamById(id: string) {
2640
return getTeamBySlug(id);
2741
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { cn } from "@/lib/utils";
2+
3+
export function DotsBackgroundPattern(props: {
4+
className?: string;
5+
}) {
6+
return (
7+
<div
8+
className={cn(
9+
"pointer-events-none absolute inset-0 text-foreground/30 dark:text-foreground/10",
10+
props.className,
11+
)}
12+
style={{
13+
backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
14+
backgroundSize: "24px 24px",
15+
maskImage:
16+
"radial-gradient(ellipse 100% 100% at 50% 50%, black 30%, transparent 50%)",
17+
}}
18+
/>
19+
);
20+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Toaster } from "@/components/ui/sonner";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { mobileViewport } from "../../../../../stories/utils";
4+
import { JoinTeamPageUI } from "./JoinTeamPage";
5+
6+
const meta = {
7+
title: "Team/Join Team",
8+
component: Story,
9+
parameters: {
10+
nextjs: {
11+
appDirectory: true,
12+
},
13+
},
14+
} satisfies Meta<typeof Story>;
15+
16+
export default meta;
17+
type Story = StoryObj<typeof meta>;
18+
19+
export const Desktop: Story = {
20+
args: {},
21+
};
22+
23+
export const Mobile: Story = {
24+
args: {},
25+
parameters: {
26+
viewport: mobileViewport("iphone14"),
27+
},
28+
};
29+
30+
function Story() {
31+
return (
32+
<div>
33+
<JoinTeamPageUI
34+
teamName="XYZ Inc"
35+
invite={async () => {
36+
await new Promise((resolve) => setTimeout(resolve, 1000));
37+
}}
38+
onInviteSuccess={() => {
39+
console.log("invite successful");
40+
}}
41+
/>
42+
<Toaster richColors />
43+
</div>
44+
);
45+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"use client";
2+
3+
import { acceptInvite } from "@/actions/acceptInvite";
4+
import type { Team } from "@/api/team";
5+
import { ToggleThemeButton } from "@/components/color-mode-toggle";
6+
import { Spinner } from "@/components/ui/Spinner/Spinner";
7+
import { DotsBackgroundPattern } from "@/components/ui/background-patterns";
8+
import { Button } from "@/components/ui/button";
9+
import { useDashboardRouter } from "@/lib/DashboardRouter";
10+
import { useMutation } from "@tanstack/react-query";
11+
import { CheckIcon, UsersIcon } from "lucide-react";
12+
import Link from "next/link";
13+
import { toast } from "sonner";
14+
import { ThirdwebMiniLogo } from "../../../../components/ThirdwebMiniLogo";
15+
16+
export function JoinTeamPage(props: {
17+
team: Team;
18+
inviteId: string;
19+
}) {
20+
const router = useDashboardRouter();
21+
return (
22+
<JoinTeamPageUI
23+
teamName={props.team.name}
24+
invite={async () => {
25+
const res = await acceptInvite({
26+
inviteId: props.inviteId,
27+
teamId: props.team.id,
28+
});
29+
30+
if (!res.ok) {
31+
throw new Error(res.errorMessage);
32+
}
33+
}}
34+
onInviteSuccess={() => {
35+
router.replace(`/teams/${props.team.slug}`);
36+
}}
37+
/>
38+
);
39+
}
40+
41+
export function JoinTeamPageUI(props: {
42+
teamName: string;
43+
invite: () => Promise<void>;
44+
onInviteSuccess: () => void;
45+
}) {
46+
return (
47+
<div className="relative flex min-h-dvh flex-col overflow-hidden">
48+
<Header />
49+
50+
<div className="container flex grow flex-col items-center justify-center ">
51+
<div className="z-10">
52+
<AcceptInviteCardUI teamName={props.teamName} invite={props.invite} />
53+
</div>
54+
</div>
55+
56+
<DotsBackgroundPattern />
57+
</div>
58+
);
59+
}
60+
61+
function Header() {
62+
return (
63+
<div className="border-b bg-background">
64+
<header className="container flex w-full flex-row items-center justify-between px-6 py-4">
65+
<div className="flex shrink-0 items-center gap-3">
66+
<ThirdwebMiniLogo className="size-7 md:size-8" />
67+
<span className="font-medium text-foreground text-xl tracking-tight">
68+
thirdweb
69+
</span>
70+
</div>
71+
72+
<div className="flex items-center gap-4">
73+
<div className="flex items-center gap-2">
74+
<Link
75+
href="/support"
76+
target="_blank"
77+
className="px-2 text-muted-foreground text-sm hover:text-foreground"
78+
>
79+
Support
80+
</Link>
81+
</div>
82+
<ToggleThemeButton />
83+
</div>
84+
</header>
85+
</div>
86+
);
87+
}
88+
89+
function AcceptInviteCardUI(props: {
90+
teamName: string;
91+
invite: () => Promise<void>;
92+
}) {
93+
const invite = useMutation({
94+
mutationFn: props.invite,
95+
});
96+
return (
97+
<div className="w-full rounded-xl border bg-card shadow-2xl lg:w-[600px]">
98+
<div className="p-4 lg:p-6">
99+
<div className="mb-5 flex size-12 items-center justify-center rounded-full border bg-background">
100+
<UsersIcon className="size-5 text-muted-foreground" />
101+
</div>
102+
103+
<h1 className="mb-3 font-semibold text-2xl tracking-tight">
104+
Join your team on thirdweb
105+
</h1>
106+
<p className="mb-1.5 font-medium text-muted-foreground">
107+
You have been invited to join team{" "}
108+
<em className="text-foreground not-italic">{props.teamName}</em>{" "}
109+
</p>
110+
111+
<p className="text-muted-foreground">
112+
Accepting this invite will add you to the team and give you access to
113+
the team&apos;s resources
114+
</p>
115+
</div>
116+
<div className="flex justify-end border-t p-4 lg:p-6">
117+
<Button
118+
disabled={invite.isPending}
119+
className="gap-2"
120+
onClick={() => {
121+
const promise = invite.mutateAsync();
122+
toast.promise(promise, {
123+
success: "Invite accepted",
124+
error: (e) => {
125+
if (e instanceof Error && e.message) {
126+
return e.message;
127+
}
128+
return "Failed to accept invite";
129+
},
130+
});
131+
}}
132+
>
133+
Accept Invite
134+
{invite.isPending ? (
135+
<Spinner className="size-4" />
136+
) : (
137+
<CheckIcon className="size-4" />
138+
)}
139+
</Button>
140+
</div>
141+
</div>
142+
);
143+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { service_getTeamBySlug } from "@/api/team";
2+
import { notFound } from "next/navigation";
3+
import { getValidAccount } from "../../../../account/settings/getAccount";
4+
import { JoinTeamPage } from "./JoinTeamPage";
5+
6+
export default async function Page(props: {
7+
params: Promise<{ team_slug: string; invite_id: string }>;
8+
}) {
9+
const { team_slug, invite_id } = await props.params;
10+
11+
// ensure the user is logged in + onboarded
12+
await getValidAccount(`/join/team/${team_slug}/${invite_id}`);
13+
14+
const team = await service_getTeamBySlug(team_slug);
15+
16+
if (!team) {
17+
notFound();
18+
}
19+
20+
return <JoinTeamPage team={team} inviteId={invite_id} />;
21+
}

apps/dashboard/src/app/login/LoginPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ function PageContent(props: {
202202
onLogout={() => {
203203
setScreen({ id: "login" });
204204
}}
205+
skipShowingPlans={props.redirectPath.startsWith("/join/team")}
205206
/>
206207
</Suspense>
207208
);

0 commit comments

Comments
 (0)