Skip to content

Commit 7775e7c

Browse files
committed
[TOOL-3473] Add Team Member invites (#6301)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## 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}` <!-- end pr-codex -->
1 parent fa6da43 commit 7775e7c

25 files changed

+1742
-379
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 sendTeamInvites(options: {
7+
teamId: string;
8+
invites: Array<{ email: string; role: "OWNER" | "MEMBER" }>;
9+
}): Promise<
10+
| {
11+
ok: true;
12+
results: Array<"fulfilled" | "rejected">;
13+
}
14+
| {
15+
ok: false;
16+
errorMessage: string;
17+
}
18+
> {
19+
const token = await getAuthToken();
20+
21+
if (!token) {
22+
return {
23+
ok: false,
24+
errorMessage: "You are not authorized to perform this action",
25+
};
26+
}
27+
28+
const results = await Promise.allSettled(
29+
options.invites.map((invite) => sendInvite(options.teamId, invite, token)),
30+
);
31+
32+
return {
33+
ok: true,
34+
results: results.map((x) => x.status),
35+
};
36+
}
37+
38+
async function sendInvite(
39+
teamId: string,
40+
invite: { email: string; role: "OWNER" | "MEMBER" },
41+
token: string,
42+
) {
43+
const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamId}/invites`, {
44+
method: "POST",
45+
headers: {
46+
Authorization: `Bearer ${token}`,
47+
"Content-Type": "application/json",
48+
},
49+
body: JSON.stringify({
50+
inviteEmail: invite.email,
51+
inviteRole: invite.role,
52+
}),
53+
});
54+
55+
if (!res.ok) {
56+
const errorMessage = await res.text();
57+
return {
58+
email: invite.email,
59+
ok: false,
60+
errorMessage,
61+
};
62+
}
63+
64+
return {
65+
email: invite.email,
66+
ok: true,
67+
};
68+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { getAuthToken } from "../../app/api/lib/getAuthToken";
2+
import { API_SERVER_URL } from "../constants/env";
3+
4+
export type TeamInvite = {
5+
id: string;
6+
teamId: string;
7+
email: string;
8+
role: "OWNER" | "MEMBER";
9+
createdAt: string;
10+
status: "pending" | "accepted" | "expired";
11+
expiresAt: string;
12+
};
13+
14+
export async function getTeamInvites(
15+
teamId: string,
16+
options: {
17+
count: number;
18+
start: number;
19+
},
20+
) {
21+
const authToken = await getAuthToken();
22+
23+
if (!authToken) {
24+
throw new Error("Unauthorized");
25+
}
26+
27+
const res = await fetch(
28+
`${API_SERVER_URL}/v1/teams/${teamId}/invites?skip=${options.start}&take=${options.count}`,
29+
{
30+
headers: {
31+
Authorization: `Bearer ${authToken}`,
32+
},
33+
},
34+
);
35+
36+
if (!res.ok) {
37+
const errorMessage = await res.text();
38+
throw new Error(errorMessage);
39+
}
40+
41+
const json = (await res.json()) as {
42+
result: TeamInvite[];
43+
};
44+
45+
return json.result;
46+
}

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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
/>
39+
<Toaster richColors />
40+
</div>
41+
);
42+
}

0 commit comments

Comments
 (0)