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/oauth/model.tsx b/apps/backend/src/oauth/model.tsx index 5af08b3189..61b374c87d 100644 --- a/apps/backend/src/oauth/model.tsx +++ b/apps/backend/src/oauth/model.tsx @@ -104,9 +104,19 @@ export class OAuthModel implements AuthorizationCodeModel { token.client = client; token.user = user; return { - ...token, + accessToken: token.accessToken, + accessTokenExpiresAt: token.accessTokenExpiresAt, + refreshToken: token.refreshToken, + refreshTokenExpiresAt: token.refreshTokenExpiresAt, + scope: token.scope, + client: token.client, + user: token.user, + + // TODO remove deprecated camelCase properties newUser: user.newUser, + is_new_user: user.newUser, afterCallbackRedirectUrl: user.afterCallbackRedirectUrl, + after_callback_redirect_url: user.afterCallbackRedirectUrl, }; } 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/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index 0ea31b814e..c2f46cb409 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -1,18 +1,62 @@ "use client"; import { UserTable } from "@/components/data-table/user-table"; +import { FormDialog } from "@/components/form-dialog"; +import { InputField, SwitchField } from "@/components/form-fields"; import { StyledLink } from "@/components/link"; -import { Alert } from "@stackframe/stack-ui"; +import { Alert, Button } from "@stackframe/stack-ui"; +import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; +function CreateDialog(props: { + open?: boolean, + onOpenChange?: (open: boolean) => void, + trigger?: React.ReactNode, +}) { + const adminApp = useAdminApp(); + const formSchema = yup.object({ + displayName: yup.string().optional(), + primaryEmail: yup.string().email().required(), + primaryEmailVerified: yup.boolean().optional(), + password: yup.string().required(), + }); + + return { + await adminApp.createUser(values); + }} + cancelButton + render={(form) => ( + <> + + +
+
+ +
+
+ +
+
+ + + + )} + />; +} + export default function PageClient() { const stackAdminApp = useAdminApp(); const allUsers = stackAdminApp.useUsers(); return ( - + Create User} />}> {allUsers.length > 0 ? null : ( Congratulations on starting your project! Check the documentation to add your first users. diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index d58946a824..9920d9f1ef 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -1,6 +1,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { camelCaseToSnakeCase } from "@stackframe/stack-shared/dist/utils/strings"; import { expect } from "vitest"; import { Context, Mailbox, NiceRequestInit, NiceResponse, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_ID, STACK_INTERNAL_PROJECT_SERVER_KEY, createMailbox, localRedirectUrl, niceFetch, updateCookiesFromResponse } from "../helpers"; @@ -52,7 +53,7 @@ function expectSnakeCase(obj: unknown, path: string): void { } } else { for (const [key, value] of Object.entries(obj)) { - if (key.match(/[a-z0-9][A-Z][a-z0-9]+/) && !key.includes("_")) { + if (key.match(/[a-z0-9][A-Z][a-z0-9]+/) && !key.includes("_") && !["newUser", "afterCallbackRedirectUrl"].includes(key)) { throw new StackAssertionError(`Object has camelCase key (expected snake case): ${path}.${key}`); } expectSnakeCase(value, `${path}.${key}`); @@ -438,6 +439,58 @@ export namespace Auth { authorizationCode: outerCallbackUrl.searchParams.get("code")!, }; } + + export async function signIn() { + const getAuthorizationCodeResult = await Auth.OAuth.getAuthorizationCode(); + + const projectKeys = backendContext.value.projectKeys; + if (projectKeys === "no-project") throw new Error("No project keys found in the backend context"); + + const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", { + method: "POST", + accessType: "client", + body: { + client_id: projectKeys.projectId, + client_secret: projectKeys.publishableClientKey ?? throwErr("No publishable client key found in the backend context"), + code: getAuthorizationCodeResult.authorizationCode, + redirect_uri: localRedirectUrl, + code_verifier: "some-code-challenge", + grant_type: "authorization_code", + }, + }); + expect(tokenResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "access_token": , + "afterCallbackRedirectUrl": null, + "after_callback_redirect_url": null, + "expires_in": 3599, + "is_new_user": true, + "newUser": true, + "refresh_token": , + "scope": "legacy", + "token_type": "Bearer", + }, + "headers": Headers { + "pragma": "no-cache", +