Skip to content

Commit 4bbead0

Browse files
fomalhautbN2D4
authored andcommitted
Team invitation (stack-auth#171)
* team invitation wip * implemented handler * team invitation callback wip * added team invitation frontend * fixed listCurrentUserTeamPermissions * added team invitation email template * fixed bugs * fixed verification code handler * added more checks to team invitation verification * fixed team invitation page * restructured verification code handler * fixed frontend * fixed team invitation tests * added more team invitation test * fixed bug * added migration file * removed unused code
1 parent 1db3512 commit 4bbead0

File tree

29 files changed

+1011
-247
lines changed

29 files changed

+1011
-247
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterEnum
2+
ALTER TYPE "EmailTemplateType" ADD VALUE 'TEAM_INVITATION';
3+
4+
-- AlterEnum
5+
ALTER TYPE "VerificationCodeType" ADD VALUE 'TEAM_INVITATION';

apps/backend/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ enum VerificationCodeType {
370370
ONE_TIME_PASSWORD
371371
PASSWORD_RESET
372372
CONTACT_CHANNEL_VERIFICATION
373+
TEAM_INVITATION
373374
}
374375

375376
// @deprecated
@@ -463,6 +464,7 @@ enum EmailTemplateType {
463464
EMAIL_VERIFICATION
464465
PASSWORD_RESET
465466
MAGIC_LINK
467+
TEAM_INVITATION
466468
}
467469

468470
model EmailTemplate {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { teamInvitationCodeHandler } from "../verification-code-handler";
2+
3+
export const POST = teamInvitationCodeHandler.checkHandler;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { teamInvitationCodeHandler } from "../verification-code-handler";
2+
3+
export const POST = teamInvitationCodeHandler.detailsHandler;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { teamInvitationCodeHandler } from "./verification-code-handler";
2+
3+
export const POST = teamInvitationCodeHandler.postHandler;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { teamMembershipsCrudHandlers } from "@/app/api/v1/team-memberships/crud";
2+
import { sendEmailFromTemplate } from "@/lib/emails";
3+
import { prismaClient } from "@/prisma-client";
4+
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
5+
import { VerificationCodeType } from "@prisma/client";
6+
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
7+
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { teamsCrudHandlers } from "../../teams/crud";
9+
10+
export const teamInvitationCodeHandler = createVerificationCodeHandler({
11+
metadata: {
12+
post: {
13+
summary: "Invite a user to a team",
14+
description: "Send an email to a user to invite them to a team",
15+
tags: ["Teams"],
16+
},
17+
check: {
18+
summary: "Check if a team invitation code is valid",
19+
description: "Check if a team invitation code is valid without using it",
20+
tags: ["Teams"],
21+
},
22+
},
23+
userRequired: true,
24+
type: VerificationCodeType.TEAM_INVITATION,
25+
data: yupObject({
26+
team_id: yupString().required(),
27+
}).required(),
28+
response: yupObject({
29+
statusCode: yupNumber().oneOf([200]).required(),
30+
bodyType: yupString().oneOf(["json"]).required(),
31+
body: yupObject({}).required(),
32+
}),
33+
detailsResponse: yupObject({
34+
statusCode: yupNumber().oneOf([200]).required(),
35+
bodyType: yupString().oneOf(["json"]).required(),
36+
body: yupObject({
37+
team_id: yupString().required(),
38+
team_display_name: yupString().required(),
39+
}).required(),
40+
}),
41+
async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }) {
42+
const team = await teamsCrudHandlers.adminRead({
43+
project: createOptions.project,
44+
team_id: createOptions.data.team_id,
45+
});
46+
47+
await sendEmailFromTemplate({
48+
project: createOptions.project,
49+
user: sendOptions.user,
50+
email: createOptions.method.email,
51+
templateType: "team_invitation",
52+
extraVariables: {
53+
teamInvitationLink: codeObj.link.toString(),
54+
teamDisplayName: team.display_name,
55+
},
56+
});
57+
},
58+
async handler(project, {}, data, body, user) {
59+
const oldMembership = await prismaClient.teamMember.findUnique({
60+
where: {
61+
projectId_projectUserId_teamId: {
62+
projectId: project.id,
63+
projectUserId: user.id,
64+
teamId: data.team_id,
65+
},
66+
},
67+
});
68+
69+
if (!oldMembership) {
70+
await teamMembershipsCrudHandlers.adminCreate({
71+
project,
72+
team_id: data.team_id,
73+
user_id: user.id,
74+
data: {},
75+
});
76+
}
77+
78+
return {
79+
statusCode: 200,
80+
bodyType: "json",
81+
body: {}
82+
};
83+
},
84+
async details(project, {}, data, body, user) {
85+
const team = await teamsCrudHandlers.adminRead({
86+
project,
87+
team_id: data.team_id,
88+
});
89+
90+
return {
91+
statusCode: 200,
92+
bodyType: "json",
93+
body: {
94+
team_id: team.id,
95+
team_display_name: team.display_name,
96+
},
97+
};
98+
}
99+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ensureUserHasTeamPermission } from "@/lib/request-checks";
2+
import { prismaClient } from "@/prisma-client";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { teamInvitationCodeHandler } from "../accept/verification-code-handler";
6+
import { listUserTeamPermissions } from "@/lib/permissions";
7+
8+
export const POST = createSmartRouteHandler({
9+
metadata: {
10+
summary: "Send an email to invite a user to a team",
11+
description: "The user receiving this email can join the team by clicking on the link in the email. If the user does not have an account yet, they will be prompted to create one.",
12+
tags: ["Emails"],
13+
},
14+
request: yupObject({
15+
auth: yupObject({
16+
type: clientOrHigherAuthTypeSchema,
17+
project: adaptSchema.required(),
18+
user: adaptSchema.required(),
19+
}).required(),
20+
body: yupObject({
21+
team_id: teamIdSchema.required(),
22+
email: teamInvitationEmailSchema.required(),
23+
callback_url: teamInvitationCallbackUrlSchema.required(),
24+
}).required(),
25+
}),
26+
response: yupObject({
27+
statusCode: yupNumber().oneOf([200]).required(),
28+
bodyType: yupString().oneOf(["success"]).required(),
29+
}),
30+
async handler({ auth, body }) {
31+
await prismaClient.$transaction(async (tx) => {
32+
if (auth.type === "client") {
33+
await ensureUserHasTeamPermission(tx, {
34+
project: auth.project,
35+
userId: auth.user.id,
36+
teamId: body.team_id,
37+
permissionId: "$invite_members"
38+
});
39+
}
40+
});
41+
42+
await teamInvitationCodeHandler.sendCode({
43+
project: auth.project,
44+
data: {
45+
team_id: body.team_id,
46+
},
47+
method: {
48+
email: body.email,
49+
},
50+
callbackUrl: body.callback_url,
51+
}, {
52+
user: auth.user,
53+
});
54+
55+
return {
56+
statusCode: 200,
57+
bodyType: "success",
58+
};
59+
},
60+
});

apps/backend/src/route-handlers/verification-code-handler.tsx

Lines changed: 71 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { SmartRouteHandler, SmartRouteHandlerOverloadMetadata, createSmartRouteH
33
import { SmartResponse } from "./smart-response";
44
import { KnownErrors } from "@stackframe/stack-shared";
55
import { prismaClient } from "@/prisma-client";
6-
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
6+
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
77
import { validateRedirectUrl } from "@/lib/redirect-urls";
88
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
99
import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
1010
import { VerificationCodeType } from "@prisma/client";
1111
import { SmartRequest } from "./smart-request";
1212
import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects";
1313
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
14+
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
1415

1516
type Method = {
1617
email: string,
@@ -30,11 +31,12 @@ type CodeObject = {
3031
expiresAt: Date,
3132
};
3233

33-
type VerificationCodeHandler<Data, SendCodeExtraOptions extends {}> = {
34+
type VerificationCodeHandler<Data, SendCodeExtraOptions extends {}, HasDetails extends boolean> = {
3435
createCode<CallbackUrl extends string | URL>(options: CreateCodeOptions<Data, CallbackUrl>): Promise<CodeObject>,
3536
sendCode(options: CreateCodeOptions<Data>, sendOptions: SendCodeExtraOptions): Promise<void>,
3637
postHandler: SmartRouteHandler<any, any, any>,
3738
checkHandler: SmartRouteHandler<any, any, any>,
39+
detailsHandler: HasDetails extends true ? SmartRouteHandler<any, any, any> : undefined,
3840
};
3941

4042
/**
@@ -44,42 +46,65 @@ export function createVerificationCodeHandler<
4446
Data,
4547
RequestBody extends {} & DeepPartial<SmartRequest["body"]>,
4648
Response extends SmartResponse,
49+
DetailsResponse extends SmartResponse | undefined,
50+
UserRequired extends boolean,
4751
SendCodeExtraOptions extends {},
4852
>(options: {
4953
metadata?: {
5054
post?: SmartRouteHandlerOverloadMetadata,
5155
check?: SmartRouteHandlerOverloadMetadata,
56+
details?: SmartRouteHandlerOverloadMetadata,
5257
},
5358
type: VerificationCodeType,
5459
data: yup.Schema<Data>,
5560
requestBody?: yup.ObjectSchema<RequestBody>,
61+
userRequired?: UserRequired,
62+
detailsResponse?: yup.Schema<DetailsResponse>,
5663
response: yup.Schema<Response>,
57-
send: (
64+
send(
5865
codeObject: CodeObject,
5966
createOptions: CreateCodeOptions<Data>,
6067
sendOptions: SendCodeExtraOptions,
61-
) => Promise<void>,
62-
handler(project: ProjectsCrud["Admin"]["Read"], method: Method, data: Data, body: RequestBody): Promise<Response>,
63-
}): VerificationCodeHandler<Data, SendCodeExtraOptions> {
64-
const createHandler = (verifyOnly: boolean) => createSmartRouteHandler({
65-
metadata: verifyOnly ? options.metadata?.check : options.metadata?.post,
68+
): Promise<void>,
69+
handler(
70+
project: ProjectsCrud["Admin"]["Read"],
71+
method: Method,
72+
data: Data,
73+
body: RequestBody,
74+
user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined
75+
): Promise<Response>,
76+
details?: DetailsResponse extends SmartResponse ? ((
77+
project: ProjectsCrud["Admin"]["Read"],
78+
method: Method,
79+
data: Data,
80+
body: RequestBody,
81+
user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined
82+
) => Promise<DetailsResponse>) : undefined,
83+
}): VerificationCodeHandler<Data, SendCodeExtraOptions, DetailsResponse extends SmartResponse ? true : false> {
84+
const createHandler = (type: 'post' | 'check' | 'details') => createSmartRouteHandler({
85+
metadata: options.metadata?.[type],
6686
request: yupObject({
6787
auth: yupObject({
6888
project: adaptSchema.required(),
89+
user: options.userRequired ? adaptSchema.required() : adaptSchema,
6990
}).required(),
7091
body: yupObject({
7192
code: yupString().required(),
7293
// we cast to undefined as a typehack because the types are a bit icky
7394
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
74-
}).concat((verifyOnly ? undefined : options.requestBody) as undefined ?? yupObject({})).required(),
95+
}).concat((type === 'post' ? options.requestBody : undefined) as undefined ?? yupObject({})).required(),
7596
}),
76-
response: verifyOnly ? yupObject({
77-
statusCode: yupNumber().oneOf([200]).required(),
78-
bodyType: yupString().oneOf(["json"]).required(),
79-
body: yupObject({
80-
"is_code_valid": yupBoolean().oneOf([true]).required(),
81-
}).required(),
82-
}).required() as yup.ObjectSchema<any> : options.response,
97+
response: type === 'check' ?
98+
yupObject({
99+
statusCode: yupNumber().oneOf([200]).required(),
100+
bodyType: yupString().oneOf(["json"]).required(),
101+
body: yupObject({
102+
"is_code_valid": yupBoolean().oneOf([true]).required(),
103+
}).required(),
104+
}).required() as yup.ObjectSchema<any> :
105+
type === 'details' ?
106+
options.detailsResponse || throwErr('detailsResponse is required') :
107+
options.response,
83108
async handler({ body: { code, ...requestBody }, auth }) {
84109
const verificationCode = await prismaClient.verificationCode.findUnique({
85110
where: {
@@ -98,28 +123,34 @@ export function createVerificationCodeHandler<
98123
strict: true,
99124
});
100125

101-
if (verifyOnly) {
102-
return {
103-
statusCode: 200,
104-
bodyType: "json",
105-
body: {
106-
is_code_valid: true,
107-
},
108-
};
109-
} else {
110-
await prismaClient.verificationCode.update({
111-
where: {
112-
projectId_code: {
113-
projectId: auth.project.id,
114-
code,
126+
switch (type) {
127+
case 'post': {
128+
await prismaClient.verificationCode.update({
129+
where: {
130+
projectId_code: {
131+
projectId: auth.project.id,
132+
code,
133+
},
115134
},
116-
},
117-
data: {
118-
usedAt: new Date(),
119-
},
120-
});
135+
data: {
136+
usedAt: new Date(),
137+
},
138+
});
121139

122-
return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any);
140+
return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any);
141+
}
142+
case 'check': {
143+
return {
144+
statusCode: 200,
145+
bodyType: "json",
146+
body: {
147+
is_code_valid: true,
148+
},
149+
};
150+
}
151+
case 'details': {
152+
return await options.details?.(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any) as any;
153+
}
123154
}
124155
},
125156
});
@@ -166,7 +197,8 @@ export function createVerificationCodeHandler<
166197
const codeObj = await this.createCode(createOptions);
167198
await options.send(codeObj, createOptions, sendOptions);
168199
},
169-
postHandler: createHandler(false),
170-
checkHandler: createHandler(true),
200+
postHandler: createHandler('post'),
201+
checkHandler: createHandler('check'),
202+
detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any,
171203
};
172-
}
204+
}

apps/dashboard/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ enum VerificationCodeType {
370370
ONE_TIME_PASSWORD
371371
PASSWORD_RESET
372372
CONTACT_CHANNEL_VERIFICATION
373+
TEAM_INVITATION
373374
}
374375

375376
// @deprecated
@@ -463,6 +464,7 @@ enum EmailTemplateType {
463464
EMAIL_VERIFICATION
464465
PASSWORD_RESET
465466
MAGIC_LINK
467+
TEAM_INVITATION
466468
}
467469

468470
model EmailTemplate {

0 commit comments

Comments
 (0)