From 58d512d37326b9e4eebf31b442c7b009a7cbbd24 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Sun, 4 Aug 2024 14:16:12 -0700 Subject: [PATCH 01/10] Team invitation (#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 --- .../migration.sql | 5 + apps/backend/prisma/schema.prisma | 2 + .../accept/check-code/route.tsx | 3 + .../team-invitations/accept/details/route.tsx | 3 + .../api/v1/team-invitations/accept/route.tsx | 3 + .../accept/verification-code-handler.tsx | 99 +++++++++++++ .../v1/team-invitations/send-code/route.tsx | 60 ++++++++ .../verification-code-handler.tsx | 110 +++++++++------ apps/dashboard/prisma/schema.prisma | 2 + apps/e2e/tests/backend/backend-helpers.ts | 40 ++++++ .../endpoints/api/v1/team-invitations.test.ts | 80 +++++++++++ .../api/v1/team-member-profiles.test.ts | 4 +- examples/demo/src/app/teams/[teamId]/page.tsx | 2 + .../app/teams/[teamId]/team-invitation.tsx | 35 +++++ .../src/templates/team-invitation.tsx | 131 ++++++++++++++++++ packages/stack-emails/src/utils.tsx | 13 ++ .../src/interface/clientInterface.ts | 61 ++++++++ .../crud-deprecated/email-templates.ts | 2 +- .../src/interface/crud/email-templates.ts | 2 +- .../interface/crud/team-invitation-details.ts | 22 +++ .../src/interface/serverInterface.ts | 18 +-- packages/stack-shared/src/schema-fields.ts | 2 + .../src/components-page/forgot-password.tsx | 57 +++++++- .../src/components-page/password-reset.tsx | 121 ++++++++++++++-- .../src/components-page/stack-handler.tsx | 20 +-- .../src/components-page/team-invitation.tsx | 124 +++++++++++++++++ .../src/components/forgot-password-form.tsx | 55 -------- .../src/components/password-reset-form.tsx | 111 --------------- packages/stack/src/lib/stack-app.ts | 71 +++++++++- 29 files changed, 1011 insertions(+), 247 deletions(-) create mode 100644 apps/backend/prisma/migrations/20240804210316_team_invitation/migration.sql create mode 100644 apps/backend/src/app/api/v1/team-invitations/accept/check-code/route.tsx create mode 100644 apps/backend/src/app/api/v1/team-invitations/accept/details/route.tsx create mode 100644 apps/backend/src/app/api/v1/team-invitations/accept/route.tsx create mode 100644 apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx create mode 100644 apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts create mode 100644 examples/demo/src/app/teams/[teamId]/team-invitation.tsx create mode 100644 packages/stack-emails/src/templates/team-invitation.tsx create mode 100644 packages/stack-shared/src/interface/crud/team-invitation-details.ts create mode 100644 packages/stack/src/components-page/team-invitation.tsx delete mode 100644 packages/stack/src/components/forgot-password-form.tsx delete mode 100644 packages/stack/src/components/password-reset-form.tsx diff --git a/apps/backend/prisma/migrations/20240804210316_team_invitation/migration.sql b/apps/backend/prisma/migrations/20240804210316_team_invitation/migration.sql new file mode 100644 index 0000000000..c2b503bf63 --- /dev/null +++ b/apps/backend/prisma/migrations/20240804210316_team_invitation/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "EmailTemplateType" ADD VALUE 'TEAM_INVITATION'; + +-- AlterEnum +ALTER TYPE "VerificationCodeType" ADD VALUE 'TEAM_INVITATION'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7e66f2fb1b..0be678afd0 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -370,6 +370,7 @@ enum VerificationCodeType { ONE_TIME_PASSWORD PASSWORD_RESET CONTACT_CHANNEL_VERIFICATION + TEAM_INVITATION } // @deprecated @@ -463,6 +464,7 @@ enum EmailTemplateType { EMAIL_VERIFICATION PASSWORD_RESET MAGIC_LINK + TEAM_INVITATION } model EmailTemplate { diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/check-code/route.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/check-code/route.tsx new file mode 100644 index 0000000000..54f90fa985 --- /dev/null +++ b/apps/backend/src/app/api/v1/team-invitations/accept/check-code/route.tsx @@ -0,0 +1,3 @@ +import { teamInvitationCodeHandler } from "../verification-code-handler"; + +export const POST = teamInvitationCodeHandler.checkHandler; diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/details/route.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/details/route.tsx new file mode 100644 index 0000000000..2262707919 --- /dev/null +++ b/apps/backend/src/app/api/v1/team-invitations/accept/details/route.tsx @@ -0,0 +1,3 @@ +import { teamInvitationCodeHandler } from "../verification-code-handler"; + +export const POST = teamInvitationCodeHandler.detailsHandler; diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/route.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/route.tsx new file mode 100644 index 0000000000..aa305d0179 --- /dev/null +++ b/apps/backend/src/app/api/v1/team-invitations/accept/route.tsx @@ -0,0 +1,3 @@ +import { teamInvitationCodeHandler } from "./verification-code-handler"; + +export const POST = teamInvitationCodeHandler.postHandler; diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx new file mode 100644 index 0000000000..b888adff2c --- /dev/null +++ b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx @@ -0,0 +1,99 @@ +import { teamMembershipsCrudHandlers } from "@/app/api/v1/team-memberships/crud"; +import { sendEmailFromTemplate } from "@/lib/emails"; +import { prismaClient } from "@/prisma-client"; +import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; +import { VerificationCodeType } from "@prisma/client"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { teamsCrudHandlers } from "../../teams/crud"; + +export const teamInvitationCodeHandler = createVerificationCodeHandler({ + metadata: { + post: { + summary: "Invite a user to a team", + description: "Send an email to a user to invite them to a team", + tags: ["Teams"], + }, + check: { + summary: "Check if a team invitation code is valid", + description: "Check if a team invitation code is valid without using it", + tags: ["Teams"], + }, + }, + userRequired: true, + type: VerificationCodeType.TEAM_INVITATION, + data: yupObject({ + team_id: yupString().required(), + }).required(), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), + body: yupObject({}).required(), + }), + detailsResponse: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), + body: yupObject({ + team_id: yupString().required(), + team_display_name: yupString().required(), + }).required(), + }), + async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }) { + const team = await teamsCrudHandlers.adminRead({ + project: createOptions.project, + team_id: createOptions.data.team_id, + }); + + await sendEmailFromTemplate({ + project: createOptions.project, + user: sendOptions.user, + email: createOptions.method.email, + templateType: "team_invitation", + extraVariables: { + teamInvitationLink: codeObj.link.toString(), + teamDisplayName: team.display_name, + }, + }); + }, + async handler(project, {}, data, body, user) { + const oldMembership = await prismaClient.teamMember.findUnique({ + where: { + projectId_projectUserId_teamId: { + projectId: project.id, + projectUserId: user.id, + teamId: data.team_id, + }, + }, + }); + + if (!oldMembership) { + await teamMembershipsCrudHandlers.adminCreate({ + project, + team_id: data.team_id, + user_id: user.id, + data: {}, + }); + } + + return { + statusCode: 200, + bodyType: "json", + body: {} + }; + }, + async details(project, {}, data, body, user) { + const team = await teamsCrudHandlers.adminRead({ + project, + team_id: data.team_id, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + team_id: team.id, + team_display_name: team.display_name, + }, + }; + } +}); diff --git a/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx b/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx new file mode 100644 index 0000000000..b5a3d2f8df --- /dev/null +++ b/apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx @@ -0,0 +1,60 @@ +import { ensureUserHasTeamPermission } from "@/lib/request-checks"; +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { teamInvitationCodeHandler } from "../accept/verification-code-handler"; +import { listUserTeamPermissions } from "@/lib/permissions"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Send an email to invite a user to a team", + 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.", + tags: ["Emails"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + project: adaptSchema.required(), + user: adaptSchema.required(), + }).required(), + body: yupObject({ + team_id: teamIdSchema.required(), + email: teamInvitationEmailSchema.required(), + callback_url: teamInvitationCallbackUrlSchema.required(), + }).required(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["success"]).required(), + }), + async handler({ auth, body }) { + await prismaClient.$transaction(async (tx) => { + if (auth.type === "client") { + await ensureUserHasTeamPermission(tx, { + project: auth.project, + userId: auth.user.id, + teamId: body.team_id, + permissionId: "$invite_members" + }); + } + }); + + await teamInvitationCodeHandler.sendCode({ + project: auth.project, + data: { + team_id: body.team_id, + }, + method: { + email: body.email, + }, + callbackUrl: body.callback_url, + }, { + user: auth.user, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index 0c1c4dec80..1bda01d73c 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -3,7 +3,7 @@ import { SmartRouteHandler, SmartRouteHandlerOverloadMetadata, createSmartRouteH import { SmartResponse } from "./smart-response"; import { KnownErrors } from "@stackframe/stack-shared"; import { prismaClient } from "@/prisma-client"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { validateRedirectUrl } from "@/lib/redirect-urls"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -11,6 +11,7 @@ import { VerificationCodeType } from "@prisma/client"; import { SmartRequest } from "./smart-request"; import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; type Method = { email: string, @@ -30,11 +31,12 @@ type CodeObject = { expiresAt: Date, }; -type VerificationCodeHandler = { +type VerificationCodeHandler = { createCode(options: CreateCodeOptions): Promise, sendCode(options: CreateCodeOptions, sendOptions: SendCodeExtraOptions): Promise, postHandler: SmartRouteHandler, checkHandler: SmartRouteHandler, + detailsHandler: HasDetails extends true ? SmartRouteHandler : undefined, }; /** @@ -44,42 +46,65 @@ export function createVerificationCodeHandler< Data, RequestBody extends {} & DeepPartial, Response extends SmartResponse, + DetailsResponse extends SmartResponse | undefined, + UserRequired extends boolean, SendCodeExtraOptions extends {}, >(options: { metadata?: { post?: SmartRouteHandlerOverloadMetadata, check?: SmartRouteHandlerOverloadMetadata, + details?: SmartRouteHandlerOverloadMetadata, }, type: VerificationCodeType, data: yup.Schema, requestBody?: yup.ObjectSchema, + userRequired?: UserRequired, + detailsResponse?: yup.Schema, response: yup.Schema, - send: ( + send( codeObject: CodeObject, createOptions: CreateCodeOptions, sendOptions: SendCodeExtraOptions, - ) => Promise, - handler(project: ProjectsCrud["Admin"]["Read"], method: Method, data: Data, body: RequestBody): Promise, -}): VerificationCodeHandler { - const createHandler = (verifyOnly: boolean) => createSmartRouteHandler({ - metadata: verifyOnly ? options.metadata?.check : options.metadata?.post, + ): Promise, + handler( + project: ProjectsCrud["Admin"]["Read"], + method: Method, + data: Data, + body: RequestBody, + user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + ): Promise, + details?: DetailsResponse extends SmartResponse ? (( + project: ProjectsCrud["Admin"]["Read"], + method: Method, + data: Data, + body: RequestBody, + user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + ) => Promise) : undefined, +}): VerificationCodeHandler { + const createHandler = (type: 'post' | 'check' | 'details') => createSmartRouteHandler({ + metadata: options.metadata?.[type], request: yupObject({ auth: yupObject({ project: adaptSchema.required(), + user: options.userRequired ? adaptSchema.required() : adaptSchema, }).required(), body: yupObject({ code: yupString().required(), // we cast to undefined as a typehack because the types are a bit icky // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - }).concat((verifyOnly ? undefined : options.requestBody) as undefined ?? yupObject({})).required(), + }).concat((type === 'post' ? options.requestBody : undefined) as undefined ?? yupObject({})).required(), }), - response: verifyOnly ? yupObject({ - statusCode: yupNumber().oneOf([200]).required(), - bodyType: yupString().oneOf(["json"]).required(), - body: yupObject({ - "is_code_valid": yupBoolean().oneOf([true]).required(), - }).required(), - }).required() as yup.ObjectSchema : options.response, + response: type === 'check' ? + yupObject({ + statusCode: yupNumber().oneOf([200]).required(), + bodyType: yupString().oneOf(["json"]).required(), + body: yupObject({ + "is_code_valid": yupBoolean().oneOf([true]).required(), + }).required(), + }).required() as yup.ObjectSchema : + type === 'details' ? + options.detailsResponse || throwErr('detailsResponse is required') : + options.response, async handler({ body: { code, ...requestBody }, auth }) { const verificationCode = await prismaClient.verificationCode.findUnique({ where: { @@ -98,28 +123,34 @@ export function createVerificationCodeHandler< strict: true, }); - if (verifyOnly) { - return { - statusCode: 200, - bodyType: "json", - body: { - is_code_valid: true, - }, - }; - } else { - await prismaClient.verificationCode.update({ - where: { - projectId_code: { - projectId: auth.project.id, - code, + switch (type) { + case 'post': { + await prismaClient.verificationCode.update({ + where: { + projectId_code: { + projectId: auth.project.id, + code, + }, }, - }, - data: { - usedAt: new Date(), - }, - }); + data: { + usedAt: new Date(), + }, + }); - return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any); + return await options.handler(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any); + } + case 'check': { + return { + statusCode: 200, + bodyType: "json", + body: { + is_code_valid: true, + }, + }; + } + case 'details': { + return await options.details?.(auth.project, { email: verificationCode.email }, validatedData as any, requestBody as any, auth.user as any) as any; + } } }, }); @@ -166,7 +197,8 @@ export function createVerificationCodeHandler< const codeObj = await this.createCode(createOptions); await options.send(codeObj, createOptions, sendOptions); }, - postHandler: createHandler(false), - checkHandler: createHandler(true), + postHandler: createHandler('post'), + checkHandler: createHandler('check'), + detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any, }; -} +} \ No newline at end of file diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index 7e66f2fb1b..0be678afd0 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -370,6 +370,7 @@ enum VerificationCodeType { ONE_TIME_PASSWORD PASSWORD_RESET CONTACT_CHANNEL_VERIFICATION + TEAM_INVITATION } // @deprecated @@ -463,6 +464,7 @@ enum EmailTemplateType { EMAIL_VERIFICATION PASSWORD_RESET MAGIC_LINK + TEAM_INVITATION } model EmailTemplate { diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index d58946a824..776abb2bc1 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -607,4 +607,44 @@ export namespace Team { teamId: response.body.id, }; } + + export async function sendInvitation(receiveMailbox: Mailbox, teamId: string) { + const response = await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "client", + body: { + email: receiveMailbox.emailAddress, + team_id: teamId, + callback_url: "http://localhost:12345/some-callback-url", + }, + }); + + return { + sendTeamInvitationResponse: response, + }; + } + + export async function acceptInvitation() { + const mailbox = backendContext.value.mailbox; + const messages = await mailbox.fetchMessages(); + const message = messages.findLast((message) => message.subject.includes("join")) ?? throwErr("Team invitation message not found"); + const code = message.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1] ?? throwErr("Team invitation code not found"); + const response = await niceBackendFetch("/api/v1/team-invitations/accept", { + method: "POST", + accessType: "client", + body: { + code, + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": {}, + "headers": Headers {