Skip to content

Commit 45e8edd

Browse files
authored
Team invitations on user (#1200)
1 parent 08c3447 commit 45e8edd

12 files changed

Lines changed: 995 additions & 51 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
2+
import { getItemQuantityForCustomer } from "@/lib/payments";
3+
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
4+
import { globalPrismaClient } from "@/prisma-client";
5+
import { VerificationCodeType } from "@/generated/prisma/client";
6+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
7+
import { KnownErrors } from "@stackframe/stack-shared";
8+
import { adaptSchema, clientOrHigherAuthTypeSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
9+
10+
export const POST = createSmartRouteHandler({
11+
metadata: {
12+
summary: "Accept a team invitation by ID",
13+
description: "Accepts a team invitation for the specified user. The user must have a verified email matching the invitation's recipient email. This marks the invitation as used and adds the user to the team.",
14+
tags: ["Teams"],
15+
},
16+
request: yupObject({
17+
auth: yupObject({
18+
type: clientOrHigherAuthTypeSchema,
19+
tenancy: adaptSchema.defined(),
20+
user: adaptSchema.optional(),
21+
}).defined(),
22+
params: yupObject({
23+
id: yupString().uuid().defined(),
24+
}).defined(),
25+
query: yupObject({
26+
user_id: userIdOrMeSchema.defined(),
27+
}).defined(),
28+
}),
29+
response: yupObject({
30+
statusCode: yupNumber().oneOf([200]).defined(),
31+
bodyType: yupString().oneOf(["json"]).defined(),
32+
body: yupObject({}).defined(),
33+
}),
34+
async handler({ auth, params, query }) {
35+
const userId = query.user_id;
36+
37+
if (auth.type === 'client') {
38+
if (!auth.user) {
39+
throw new KnownErrors.CannotGetOwnUserWithoutUser();
40+
}
41+
if (userId !== auth.user.id) {
42+
throw new KnownErrors.CannotGetOwnUserWithoutUser();
43+
}
44+
45+
if (auth.user.restricted_reason) {
46+
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(auth.user.restricted_reason);
47+
}
48+
}
49+
50+
// Look up the invitation (verification code) by ID
51+
const code = await globalPrismaClient.verificationCode.findUnique({
52+
where: {
53+
projectId_branchId_id: {
54+
projectId: auth.tenancy.project.id,
55+
branchId: auth.tenancy.branchId,
56+
id: params.id,
57+
},
58+
type: VerificationCodeType.TEAM_INVITATION,
59+
usedAt: null,
60+
expiresAt: { gt: new Date() },
61+
},
62+
});
63+
64+
if (!code) {
65+
throw new KnownErrors.VerificationCodeNotFound();
66+
}
67+
68+
const invitationData = code.data as { team_id: string };
69+
const invitationMethod = code.method as { email: string };
70+
71+
// Verify that the target user has a verified email matching the invitation's recipient
72+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
73+
const matchingChannel = await prisma.contactChannel.findFirst({
74+
where: {
75+
tenancyId: auth.tenancy.id,
76+
projectUserId: userId,
77+
type: 'EMAIL',
78+
isVerified: true,
79+
value: invitationMethod.email,
80+
},
81+
});
82+
83+
if (!matchingChannel) {
84+
throw new KnownErrors.VerificationCodeNotFound();
85+
}
86+
87+
await retryTransaction(prisma, async (tx) => {
88+
// Internal project payment checks (same as in the verification code handler)
89+
if (auth.tenancy.project.id === "internal") {
90+
const currentMemberCount = await tx.teamMember.count({
91+
where: {
92+
tenancyId: auth.tenancy.id,
93+
teamId: invitationData.team_id,
94+
},
95+
});
96+
const maxDashboardAdmins = await getItemQuantityForCustomer({
97+
prisma: tx,
98+
tenancy: auth.tenancy,
99+
customerId: invitationData.team_id,
100+
itemId: "dashboard_admins",
101+
customerType: "team",
102+
});
103+
if (currentMemberCount + 1 > maxDashboardAdmins) {
104+
throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", invitationData.team_id, -1);
105+
}
106+
}
107+
108+
const oldMembership = await tx.teamMember.findUnique({
109+
where: {
110+
tenancyId_projectUserId_teamId: {
111+
tenancyId: auth.tenancy.id,
112+
projectUserId: userId,
113+
teamId: invitationData.team_id,
114+
},
115+
},
116+
});
117+
118+
if (!oldMembership) {
119+
await teamMembershipsCrudHandlers.adminCreate({
120+
tenancy: auth.tenancy,
121+
team_id: invitationData.team_id,
122+
user_id: userId,
123+
data: {},
124+
});
125+
}
126+
127+
// Mark the invitation as used inside the transaction to prevent race conditions
128+
const updated = await globalPrismaClient.verificationCode.updateMany({
129+
where: {
130+
projectId: auth.tenancy.project.id,
131+
branchId: auth.tenancy.branchId,
132+
id: params.id,
133+
usedAt: null,
134+
},
135+
data: {
136+
usedAt: new Date(),
137+
},
138+
});
139+
140+
if (updated.count === 0) {
141+
throw new KnownErrors.VerificationCodeNotFound();
142+
}
143+
});
144+
145+
return {
146+
statusCode: 200,
147+
bodyType: "json",
148+
body: {},
149+
};
150+
},
151+
});

apps/backend/src/app/api/latest/team-invitations/crud.tsx

Lines changed: 114 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,156 @@
1+
import { VerificationCodeType } from "@/generated/prisma/client";
12
import { ensureTeamExists, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks";
2-
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
3+
import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client";
34
import { createCrudHandlers } from "@/route-handlers/crud-handler";
45
import { KnownErrors } from "@stackframe/stack-shared";
56
import { teamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
6-
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7-
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
7+
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
89
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
10+
import { teamsCrudHandlers } from "../teams/crud";
911
import { teamInvitationCodeHandler } from "./accept/verification-code-handler";
1012

1113
export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamInvitationCrud, {
1214
querySchema: yupObject({
13-
team_id: yupString().uuid().defined().meta({ openapiField: { onlyShowInOperations: ['List'] } }),
15+
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List', 'Delete'], description: 'The team ID to list invitations for. Required unless user_id is provided.' } }),
16+
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'List invitations sent to this user\'s verified emails. Must be "me" for client access. Cannot be combined with team_id.' } }),
1417
}),
1518
paramsSchema: yupObject({
1619
id: yupString().uuid().defined(),
1720
}),
1821
onList: async ({ auth, query }) => {
22+
if (query.team_id != null && query.user_id != null) {
23+
throw new StatusError(StatusError.BadRequest, "Cannot specify both team_id and user_id");
24+
}
25+
if (query.team_id == null && query.user_id == null) {
26+
throw new StatusError(StatusError.BadRequest, "Must specify either team_id or user_id");
27+
}
28+
29+
if (query.user_id != null) {
30+
// List invitations sent to the user's verified emails
31+
if (auth.type === 'client') {
32+
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
33+
if (query.user_id !== currentUserId) {
34+
throw new KnownErrors.CannotGetOwnUserWithoutUser();
35+
}
36+
}
37+
38+
const targetUserId = query.user_id;
39+
40+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
41+
const verifiedEmails = await prisma.contactChannel.findMany({
42+
where: {
43+
tenancyId: auth.tenancy.id,
44+
projectUserId: targetUserId,
45+
type: 'EMAIL',
46+
isVerified: true,
47+
},
48+
select: { value: true },
49+
});
50+
51+
if (verifiedEmails.length === 0) {
52+
return { items: [], is_paginated: false };
53+
}
54+
55+
const codes = await globalPrismaClient.verificationCode.findMany({
56+
where: {
57+
projectId: auth.tenancy.project.id,
58+
branchId: auth.tenancy.branchId,
59+
type: VerificationCodeType.TEAM_INVITATION,
60+
usedAt: null,
61+
expiresAt: { gt: new Date() },
62+
OR: verifiedEmails.map(({ value }) => ({
63+
method: { path: ['email'], equals: value },
64+
})),
65+
},
66+
});
67+
68+
const teamIds = [...new Set(codes.map(code => {
69+
const data = code.data as { team_id: string };
70+
return data.team_id;
71+
}))];
72+
73+
const teamsMap = new Map<string, string>();
74+
for (const teamId of teamIds) {
75+
try {
76+
const team = await teamsCrudHandlers.adminRead({
77+
tenancy: auth.tenancy,
78+
team_id: teamId,
79+
allowedErrorTypes: [KnownErrors.TeamNotFound],
80+
});
81+
teamsMap.set(teamId, team.display_name);
82+
} catch (e) {
83+
if (KnownErrors.TeamNotFound.isInstance(e)) {
84+
// Team may have been deleted since the invitation was created; skip these invitations
85+
continue;
86+
}
87+
throw e;
88+
}
89+
}
90+
91+
return {
92+
items: codes
93+
.filter(code => {
94+
const data = code.data as { team_id: string };
95+
return teamsMap.has(data.team_id);
96+
})
97+
.map(code => {
98+
const data = code.data as { team_id: string };
99+
const method = code.method as { email: string };
100+
return {
101+
id: code.id,
102+
team_id: data.team_id,
103+
team_display_name: teamsMap.get(data.team_id) ?? throwErr("team_display_name should be available after filtering; this should never happen"),
104+
expires_at_millis: code.expiresAt.getTime(),
105+
recipient_email: method.email,
106+
};
107+
}),
108+
is_paginated: false,
109+
};
110+
}
111+
112+
// List invitations for a specific team (existing behavior)
113+
const teamId = query.team_id ?? throwErr("team_id is required when user_id is not provided; this should never happen because of the earlier validation");
19114
const prisma = await getPrismaClientForTenancy(auth.tenancy);
20115
return await retryTransaction(prisma, async (tx) => {
21116
if (auth.type === 'client') {
22-
// Client can only:
23-
// - list invitations in their own team if they have the $read_members AND $invite_members permissions
24117
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
25118

26-
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id, userId: currentUserId });
119+
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId, userId: currentUserId });
27120

28121
for (const permissionId of ['$read_members', '$invite_members']) {
29122
await ensureUserTeamPermissionExists(tx, {
30123
tenancy: auth.tenancy,
31-
teamId: query.team_id,
124+
teamId,
32125
userId: currentUserId,
33126
permissionId,
34127
errorType: 'required',
35128
recursive: true,
36129
});
37130
}
38131
} else {
39-
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id });
132+
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId });
40133
}
41134

42135
const allCodes = await teamInvitationCodeHandler.listCodes({
43136
tenancy: auth.tenancy,
44137
dataFilter: {
45138
path: ['team_id'],
46-
equals: query.team_id,
139+
equals: teamId,
47140
},
48141
});
49142

143+
const team = await teamsCrudHandlers.adminRead({
144+
tenancy: auth.tenancy,
145+
team_id: teamId,
146+
});
147+
const teamDisplayName = team.display_name;
148+
50149
return {
51150
items: allCodes.map(code => ({
52151
id: code.id,
53152
team_id: code.data.team_id,
153+
team_display_name: teamDisplayName,
54154
expires_at_millis: code.expiresAt.getTime(),
55155
recipient_email: code.method.email,
56156
})),
@@ -59,26 +159,24 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
59159
});
60160
},
61161
onDelete: async ({ auth, query, params }) => {
162+
const teamId = query.team_id ?? throwErr(new StatusError(StatusError.BadRequest, "team_id is required for deleting a team invitation"));
62163
const prisma = await getPrismaClientForTenancy(auth.tenancy);
63164
await retryTransaction(prisma, async (tx) => {
64165
if (auth.type === 'client') {
65-
// Client can only:
66-
// - delete invitations in their own team if they have the $remove_members permissions
67-
68166
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
69167

70-
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id, userId: currentUserId });
168+
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId, userId: currentUserId });
71169

72170
await ensureUserTeamPermissionExists(tx, {
73171
tenancy: auth.tenancy,
74-
teamId: query.team_id,
172+
teamId,
75173
userId: currentUserId,
76174
permissionId: "$remove_members",
77175
errorType: 'required',
78176
recursive: true,
79177
});
80178
} else {
81-
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId: query.team_id });
179+
await ensureTeamExists(tx, { tenancyId: auth.tenancy.id, teamId });
82180
}
83181
});
84182

0 commit comments

Comments
 (0)