UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

552 lines (550 loc) • 24.1 kB
import { getDate } from "../../../utils/date.mjs"; import { toZodSchema } from "../../../db/to-zod.mjs"; import "../../../db/index.mjs"; import { setSessionCookie } from "../../../cookies/index.mjs"; import { getSessionFromCtx } from "../../../api/routes/session.mjs"; import "../../../api/routes/index.mjs"; import { defaultRoles } from "../access/statement.mjs"; import { getOrgAdapter } from "../adapter.mjs"; import { orgMiddleware, orgSessionMiddleware } from "../call.mjs"; import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs"; import { hasPermission } from "../has-permission.mjs"; import { parseRoles } from "../organization.mjs"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { APIError } from "better-call"; import { createAuthEndpoint } from "@better-auth/core/api"; //#region src/plugins/organization/routes/crud-invites.ts const baseInvitationSchema = z.object({ email: z.string().meta({ description: "The email address of the user to invite" }), role: z.union([z.string().meta({ description: "The role to assign to the user" }), z.array(z.string().meta({ description: "The roles to assign to the user" }))]).meta({ description: "The role(s) to assign to the user. It can be `admin`, `member`, owner. Eg: \"member\"" }), organizationId: z.string().meta({ description: "The organization ID to invite the user to" }).optional(), resend: z.boolean().meta({ description: "Resend the invitation email, if the user is already invited. Eg: true" }).optional(), teamId: z.union([z.string().meta({ description: "The team ID to invite the user to" }).optional(), z.array(z.string()).meta({ description: "The team IDs to invite the user to" }).optional()]) }); const createInvitation = (option) => { const additionalFieldsSchema = toZodSchema({ fields: option?.schema?.invitation?.additionalFields || {}, isClientSide: true }); return createAuthEndpoint("/organization/invite-member", { method: "POST", requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], body: z.object({ ...baseInvitationSchema.shape, ...additionalFieldsSchema.shape }), metadata: { $Infer: { body: {} }, openapi: { operationId: "createOrganizationInvitation", description: "Create an invitation to an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, email: { type: "string" }, role: { type: "string" }, organizationId: { type: "string" }, inviterId: { type: "string" }, status: { type: "string" }, expiresAt: { type: "string" }, createdAt: { type: "string" } }, required: [ "id", "email", "role", "organizationId", "inviterId", "status", "expiresAt", "createdAt" ] } } } } } } } }, async (ctx) => { const session = ctx.context.session; const organizationId = ctx.body.organizationId || session.session.activeOrganizationId; if (!organizationId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); const email = ctx.body.email.toLowerCase(); if (!z.email().safeParse(email).success) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_EMAIL }); const adapter = getOrgAdapter(ctx.context, option); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); if (!await hasPermission({ role: member.role, options: ctx.context.orgOptions, permissions: { invitation: ["create"] }, organizationId }, ctx)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION }); const creatorRole = ctx.context.orgOptions.creatorRole || "owner"; const roles = parseRoles(ctx.body.role); const rolesArray = roles.split(",").map((r) => r.trim()).filter(Boolean); const defaults = Object.keys(defaultRoles); const customRoles = Object.keys(ctx.context.orgOptions.roles || {}); const validStaticRoles = new Set([...defaults, ...customRoles]); const unknownRoles = rolesArray.filter((role) => !validStaticRoles.has(role)); if (unknownRoles.length > 0) if (ctx.context.orgOptions.dynamicAccessControl?.enabled) { const foundRoleNames = (await ctx.context.adapter.findMany({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId }, { field: "role", value: unknownRoles, operator: "in" }] })).map((r) => r.role); const stillInvalid = unknownRoles.filter((r) => !foundRoleNames.includes(r)); if (stillInvalid.length > 0) throw new APIError("BAD_REQUEST", { message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND}: ${stillInvalid.join(", ")}` }); } else throw new APIError("BAD_REQUEST", { message: `${ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND}: ${unknownRoles.join(", ")}` }); if (member.role !== creatorRole && roles.split(",").includes(creatorRole)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE }); if (await adapter.findMemberByEmail({ email, organizationId })) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION }); const alreadyInvited = await adapter.findPendingInvitation({ email, organizationId }); if (alreadyInvited.length && !ctx.body.resend) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION }); const organization = await adapter.findOrganizationById(organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); if (alreadyInvited.length && ctx.body.resend) { const existingInvitation = alreadyInvited[0]; const newExpiresAt = getDate(ctx.context.orgOptions.invitationExpiresIn || 3600 * 48, "sec"); await ctx.context.adapter.update({ model: "invitation", where: [{ field: "id", value: existingInvitation.id }], update: { expiresAt: newExpiresAt } }); const updatedInvitation = { ...existingInvitation, expiresAt: newExpiresAt }; if (ctx.context.orgOptions.sendInvitationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.orgOptions.sendInvitationEmail({ id: updatedInvitation.id, role: updatedInvitation.role, email: updatedInvitation.email.toLowerCase(), organization, inviter: { ...member, user: session.user }, invitation: updatedInvitation }, ctx.request)); return ctx.json(updatedInvitation); } if (alreadyInvited.length && ctx.context.orgOptions.cancelPendingInvitationsOnReInvite) await adapter.updateInvitation({ invitationId: alreadyInvited[0].id, status: "canceled" }); const invitationLimit = typeof ctx.context.orgOptions.invitationLimit === "function" ? await ctx.context.orgOptions.invitationLimit({ user: session.user, organization, member }, ctx.context) : ctx.context.orgOptions.invitationLimit ?? 100; if ((await adapter.findPendingInvitations({ organizationId })).length >= invitationLimit) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.INVITATION_LIMIT_REACHED }); if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== "undefined" && "teamId" in ctx.body && ctx.body.teamId) { const teamIds$1 = typeof ctx.body.teamId === "string" ? [ctx.body.teamId] : ctx.body.teamId; for (const teamId of teamIds$1) { const team = await adapter.findTeamById({ teamId, organizationId, includeTeamMembers: true }); if (!team) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND }); const maximumMembersPerTeam = typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === "function" ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({ teamId, session, organizationId }) : ctx.context.orgOptions.teams.maximumMembersPerTeam; if (team.members.length >= maximumMembersPerTeam) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED }); } } const teamIds = "teamId" in ctx.body ? typeof ctx.body.teamId === "string" ? [ctx.body.teamId] : ctx.body.teamId ?? [] : []; const { email: _, role: __, organizationId: ___, resend: ____, ...additionalFields } = ctx.body; let invitationData = { role: roles, email, organizationId, teamIds, ...additionalFields ? additionalFields : {} }; if (option?.organizationHooks?.beforeCreateInvitation) { const response = await option?.organizationHooks.beforeCreateInvitation({ invitation: { ...invitationData, inviterId: session.user.id, teamId: teamIds.length > 0 ? teamIds[0] : void 0 }, inviter: session.user, organization }); if (response && typeof response === "object" && "data" in response) invitationData = { ...invitationData, ...response.data }; } const invitation = await adapter.createInvitation({ invitation: invitationData, user: session.user }); if (ctx.context.orgOptions.sendInvitationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.orgOptions.sendInvitationEmail({ id: invitation.id, role: invitation.role, email: invitation.email.toLowerCase(), organization, inviter: { ...member, user: session.user }, invitation }, ctx.request)); if (option?.organizationHooks?.afterCreateInvitation) await option?.organizationHooks.afterCreateInvitation({ invitation, inviter: session.user, organization }); return ctx.json(invitation); }); }; const acceptInvitationBodySchema = z.object({ invitationId: z.string().meta({ description: "The ID of the invitation to accept" }) }); const acceptInvitation = (options) => createAuthEndpoint("/organization/accept-invitation", { method: "POST", body: acceptInvitationBodySchema, requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], metadata: { openapi: { description: "Accept an invitation to an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { invitation: { type: "object" }, member: { type: "object" } } } } } } } } } }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, options); const invitation = await adapter.findInvitationById(ctx.body.invitationId); if (!invitation || invitation.expiresAt < /* @__PURE__ */ new Date() || invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND }); if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION }); if (ctx.context.orgOptions.requireEmailVerificationOnInvitation && !session.user.emailVerified) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION }); const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; if (await adapter.countMembers({ organizationId: invitation.organizationId }) >= membershipLimit) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED }); const organization = await adapter.findOrganizationById(invitation.organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); if (options?.organizationHooks?.beforeAcceptInvitation) await options?.organizationHooks.beforeAcceptInvitation({ invitation, user: session.user, organization }); const acceptedI = await adapter.updateInvitation({ invitationId: ctx.body.invitationId, status: "accepted" }); if (!acceptedI) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION }); if (ctx.context.orgOptions.teams && ctx.context.orgOptions.teams.enabled && "teamId" in acceptedI && acceptedI.teamId) { const teamIds = acceptedI.teamId.split(","); const onlyOne = teamIds.length === 1; for (const teamId of teamIds) { await adapter.findOrCreateTeamMember({ teamId, userId: session.user.id }); if (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== "undefined") { if (await adapter.countTeamMembers({ teamId }) >= (typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === "function" ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({ teamId, session, organizationId: invitation.organizationId }) : ctx.context.orgOptions.teams.maximumMembersPerTeam)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED }); } } if (onlyOne) { const teamId = teamIds[0]; await setSessionCookie(ctx, { session: await adapter.setActiveTeam(session.session.token, teamId, ctx), user: session.user }); } } const member = await adapter.createMember({ organizationId: invitation.organizationId, userId: session.user.id, role: invitation.role, createdAt: /* @__PURE__ */ new Date() }); await adapter.setActiveOrganization(session.session.token, invitation.organizationId, ctx); if (!acceptedI) return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND } }); if (options?.organizationHooks?.afterAcceptInvitation) await options?.organizationHooks.afterAcceptInvitation({ invitation: acceptedI, member, user: session.user, organization }); return ctx.json({ invitation: acceptedI, member }); }); const rejectInvitationBodySchema = z.object({ invitationId: z.string().meta({ description: "The ID of the invitation to reject" }) }); const rejectInvitation = (options) => createAuthEndpoint("/organization/reject-invitation", { method: "POST", body: rejectInvitationBodySchema, requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], metadata: { openapi: { description: "Reject an invitation to an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { invitation: { type: "object" }, member: { type: "object", nullable: true } } } } } } } } } }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); const invitation = await adapter.findInvitationById(ctx.body.invitationId); if (!invitation || invitation.expiresAt < /* @__PURE__ */ new Date() || invitation.status !== "pending") throw new APIError("BAD_REQUEST", { message: "Invitation not found!" }); if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION }); if (ctx.context.orgOptions.requireEmailVerificationOnInvitation && !session.user.emailVerified) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION }); const organization = await adapter.findOrganizationById(invitation.organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); if (options?.organizationHooks?.beforeRejectInvitation) await options?.organizationHooks.beforeRejectInvitation({ invitation, user: session.user, organization }); const rejectedI = await adapter.updateInvitation({ invitationId: ctx.body.invitationId, status: "rejected" }); if (options?.organizationHooks?.afterRejectInvitation) await options?.organizationHooks.afterRejectInvitation({ invitation: rejectedI || invitation, user: session.user, organization }); return ctx.json({ invitation: rejectedI, member: null }); }); const cancelInvitationBodySchema = z.object({ invitationId: z.string().meta({ description: "The ID of the invitation to cancel" }) }); const cancelInvitation = (options) => createAuthEndpoint("/organization/cancel-invitation", { method: "POST", body: cancelInvitationBodySchema, requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], openapi: { operationId: "cancelOrganizationInvitation", description: "Cancel an invitation to an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { invitation: { type: "object" } } } } } } } } }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, options); const invitation = await adapter.findInvitationById(ctx.body.invitationId); if (!invitation) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND }); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: invitation.organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); if (!await hasPermission({ role: member.role, options: ctx.context.orgOptions, permissions: { invitation: ["cancel"] }, organizationId: invitation.organizationId }, ctx)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION }); const organization = await adapter.findOrganizationById(invitation.organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); if (options?.organizationHooks?.beforeCancelInvitation) await options?.organizationHooks.beforeCancelInvitation({ invitation, cancelledBy: session.user, organization }); const canceledI = await adapter.updateInvitation({ invitationId: ctx.body.invitationId, status: "canceled" }); if (options?.organizationHooks?.afterCancelInvitation) await options?.organizationHooks.afterCancelInvitation({ invitation: canceledI || invitation, cancelledBy: session.user, organization }); return ctx.json(canceledI); }); const getInvitationQuerySchema = z.object({ id: z.string().meta({ description: "The ID of the invitation to get" }) }); const getInvitation = (options) => createAuthEndpoint("/organization/get-invitation", { method: "GET", use: [orgMiddleware], requireHeaders: true, query: getInvitationQuerySchema, metadata: { openapi: { description: "Get an invitation by ID", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, email: { type: "string" }, role: { type: "string" }, organizationId: { type: "string" }, inviterId: { type: "string" }, status: { type: "string" }, expiresAt: { type: "string" }, organizationName: { type: "string" }, organizationSlug: { type: "string" }, inviterEmail: { type: "string" } }, required: [ "id", "email", "role", "organizationId", "inviterId", "status", "expiresAt", "organizationName", "organizationSlug", "inviterEmail" ] } } } } } } } }, async (ctx) => { const session = await getSessionFromCtx(ctx); if (!session) throw new APIError("UNAUTHORIZED", { message: "Not authenticated" }); const adapter = getOrgAdapter(ctx.context, options); const invitation = await adapter.findInvitationById(ctx.query.id); if (!invitation || invitation.status !== "pending" || invitation.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("BAD_REQUEST", { message: "Invitation not found!" }); if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION }); const organization = await adapter.findOrganizationById(invitation.organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); const member = await adapter.findMemberByOrgId({ userId: invitation.inviterId, organizationId: invitation.organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION }); return ctx.json({ ...invitation, organizationName: organization.name, organizationSlug: organization.slug, inviterEmail: member.user.email }); }); const listInvitationQuerySchema = z.object({ organizationId: z.string().meta({ description: "The ID of the organization to list invitations for" }).optional() }).optional(); const listInvitations = (options) => createAuthEndpoint("/organization/list-invitations", { method: "GET", requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], query: listInvitationQuerySchema }, async (ctx) => { const session = await getSessionFromCtx(ctx); if (!session) throw new APIError("UNAUTHORIZED", { message: "Not authenticated" }); const orgId = ctx.query?.organizationId || session.session.activeOrganizationId; if (!orgId) throw new APIError("BAD_REQUEST", { message: "Organization ID is required" }); const adapter = getOrgAdapter(ctx.context, options); if (!await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: orgId })) throw new APIError("FORBIDDEN", { message: "You are not a member of this organization" }); const invitations = await adapter.listInvitations({ organizationId: orgId }); return ctx.json(invitations); }); /** * List all invitations a user has received */ const listUserInvitations = (options) => createAuthEndpoint("/organization/list-user-invitations", { method: "GET", use: [orgMiddleware], query: z.object({ email: z.string().meta({ description: "The email of the user to list invitations for. This only works for server side API calls." }).optional() }).optional(), metadata: { openapi: { description: "List all invitations a user has received", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { id: { type: "string" }, email: { type: "string" }, role: { type: "string" }, organizationId: { type: "string" }, organizationName: { type: "string" }, inviterId: { type: "string", description: "The ID of the user who created the invitation" }, teamId: { type: "string", description: "The ID of the team associated with the invitation", nullable: true }, status: { type: "string" }, expiresAt: { type: "string" }, createdAt: { type: "string" } }, required: [ "id", "email", "role", "organizationId", "organizationName", "inviterId", "status", "expiresAt", "createdAt" ] } } } } } } } } }, async (ctx) => { const session = await getSessionFromCtx(ctx); if (ctx.request && ctx.query?.email) throw new APIError("BAD_REQUEST", { message: "User email cannot be passed for client side API calls." }); const userEmail = session?.user.email || ctx.query?.email; if (!userEmail) throw new APIError("BAD_REQUEST", { message: "Missing session headers, or email query parameter." }); const invitations = await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail); return ctx.json(invitations); }); //#endregion export { acceptInvitation, cancelInvitation, createInvitation, getInvitation, listInvitations, listUserInvitations, rejectInvitation }; //# sourceMappingURL=crud-invites.mjs.map