UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

485 lines (483 loc) • 22.1 kB
import { toZodSchema } from "../../../db/to-zod.mjs"; import { getSessionFromCtx, sessionMiddleware } from "../../../api/routes/session.mjs"; import "../../../api/index.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-members.ts const baseMemberSchema = z.object({ userId: z.coerce.string().meta({ description: "The user Id which represents the user to be added as a member. If `null` is provided, then it's expected to provide session headers. Eg: \"user-id\"" }), role: z.union([z.string(), z.array(z.string())]).meta({ description: "The role(s) to assign to the new member. Eg: [\"admin\", \"sale\"]" }), organizationId: z.string().meta({ description: "An optional organization ID to pass. If not provided, will default to the user's active organization. Eg: \"org-id\"" }).optional(), teamId: z.string().meta({ description: "An optional team ID to add the member to. Eg: \"team-id\"" }).optional() }); const addMember = (option) => { const additionalFieldsSchema = toZodSchema({ fields: option?.schema?.member?.additionalFields || {}, isClientSide: true }); return createAuthEndpoint({ method: "POST", body: z.object({ ...baseMemberSchema.shape, ...additionalFieldsSchema.shape }), use: [orgMiddleware], metadata: { $Infer: { body: {} }, openapi: { operationId: "addOrganizationMember", description: "Add a member to an organization" } } }, async (ctx) => { const session = ctx.body.userId ? await getSessionFromCtx(ctx).catch((e) => null) : null; const orgId = ctx.body.organizationId || session?.session.activeOrganizationId; if (!orgId) return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION } }); const teamId = "teamId" in ctx.body ? ctx.body.teamId : void 0; if (teamId && !ctx.context.orgOptions.teams?.enabled) { ctx.context.logger.error("Teams are not enabled"); throw new APIError("BAD_REQUEST", { message: "Teams are not enabled" }); } const adapter = getOrgAdapter(ctx.context, option); const user = await ctx.context.internalAdapter.findUserById(ctx.body.userId); if (!user) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.USER_NOT_FOUND }); if (await adapter.findMemberByEmail({ email: user.email, organizationId: orgId })) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION }); if (teamId) { const team = await adapter.findTeamById({ teamId, organizationId: orgId }); if (!team || team.organizationId !== orgId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND }); } const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; if (await adapter.countMembers({ organizationId: orgId }) >= membershipLimit) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED }); const { role: _, userId: __, organizationId: ___, ...additionalFields } = ctx.body; const organization = await adapter.findOrganizationById(orgId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); let memberData = { organizationId: orgId, userId: user.id, role: parseRoles(ctx.body.role), createdAt: /* @__PURE__ */ new Date(), ...additionalFields ? additionalFields : {} }; if (option?.organizationHooks?.beforeAddMember) { const response = await option?.organizationHooks.beforeAddMember({ member: { userId: user.id, organizationId: orgId, role: parseRoles(ctx.body.role), ...additionalFields }, user, organization }); if (response && typeof response === "object" && "data" in response) memberData = { ...memberData, ...response.data }; } const createdMember = await adapter.createMember(memberData); if (teamId) await adapter.findOrCreateTeamMember({ userId: user.id, teamId }); if (option?.organizationHooks?.afterAddMember) await option?.organizationHooks.afterAddMember({ member: createdMember, user, organization }); return ctx.json(createdMember); }); }; const removeMemberBodySchema = z.object({ memberIdOrEmail: z.string().meta({ description: "The ID or email of the member to remove" }), organizationId: z.string().meta({ description: "The ID of the organization to remove the member from. If not provided, the active organization will be used. Eg: \"org-id\"" }).optional() }); const removeMember = (options) => createAuthEndpoint("/organization/remove-member", { method: "POST", body: removeMemberBodySchema, requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware], metadata: { openapi: { description: "Remove a member from an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { member: { type: "object", properties: { id: { type: "string" }, userId: { type: "string" }, organizationId: { type: "string" }, role: { type: "string" } }, required: [ "id", "userId", "organizationId", "role" ] } }, required: ["member"] } } } } } } } }, async (ctx) => { const session = ctx.context.session; const organizationId = ctx.body.organizationId || session.session.activeOrganizationId; if (!organizationId) return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION } }); const adapter = getOrgAdapter(ctx.context, options); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); let toBeRemovedMember = null; if (ctx.body.memberIdOrEmail.includes("@")) toBeRemovedMember = await adapter.findMemberByEmail({ email: ctx.body.memberIdOrEmail, organizationId }); else { const result = await adapter.findMemberById(ctx.body.memberIdOrEmail); if (!result) toBeRemovedMember = null; else { const { user: _user, ...member$1 } = result; toBeRemovedMember = member$1; } } if (!toBeRemovedMember) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); const roles = toBeRemovedMember.role.split(","); const creatorRole = ctx.context.orgOptions?.creatorRole || "owner"; if (roles.includes(creatorRole)) { if (member.role !== creatorRole) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER }); const { members } = await adapter.listMembers({ organizationId }); if (members.filter((member$1) => { return member$1.role.split(",").includes(creatorRole); }).length <= 1) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER }); } if (!await hasPermission({ role: member.role, options: ctx.context.orgOptions, permissions: { member: ["delete"] }, organizationId }, ctx)) throw new APIError("UNAUTHORIZED", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER }); if (toBeRemovedMember?.organizationId !== organizationId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); const organization = await adapter.findOrganizationById(organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); const userBeingRemoved = await ctx.context.internalAdapter.findUserById(toBeRemovedMember.userId); if (!userBeingRemoved) throw new APIError("BAD_REQUEST", { message: "User not found" }); if (options?.organizationHooks?.beforeRemoveMember) await options?.organizationHooks.beforeRemoveMember({ member: toBeRemovedMember, user: userBeingRemoved, organization }); await adapter.deleteMember({ memberId: toBeRemovedMember.id, organizationId, userId: toBeRemovedMember.userId }); if (session.user.id === toBeRemovedMember.userId && session.session.activeOrganizationId === toBeRemovedMember.organizationId) await adapter.setActiveOrganization(session.session.token, null, ctx); if (options?.organizationHooks?.afterRemoveMember) await options?.organizationHooks.afterRemoveMember({ member: toBeRemovedMember, user: userBeingRemoved, organization }); return ctx.json({ member: toBeRemovedMember }); }); const updateMemberRoleBodySchema = z.object({ role: z.union([z.string(), z.array(z.string())]).meta({ description: "The new role to be applied. This can be a string or array of strings representing the roles. Eg: [\"admin\", \"sale\"]" }), memberId: z.string().meta({ description: "The member id to apply the role update to. Eg: \"member-id\"" }), organizationId: z.string().meta({ description: "An optional organization ID which the member is a part of to apply the role update. If not provided, you must provide session headers to get the active organization. Eg: \"organization-id\"" }).optional() }); const updateMemberRole = (option) => createAuthEndpoint("/organization/update-member-role", { method: "POST", body: updateMemberRoleBodySchema, use: [orgMiddleware, orgSessionMiddleware], requireHeaders: true, metadata: { $Infer: { body: {} }, openapi: { operationId: "updateOrganizationMemberRole", description: "Update the role of a member in an organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { member: { type: "object", properties: { id: { type: "string" }, userId: { type: "string" }, organizationId: { type: "string" }, role: { type: "string" } }, required: [ "id", "userId", "organizationId", "role" ] } }, required: ["member"] } } } } } } } }, async (ctx) => { const session = ctx.context.session; if (!ctx.body.role) throw new APIError("BAD_REQUEST"); const organizationId = ctx.body.organizationId || session.session.activeOrganizationId; if (!organizationId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); const roleToSet = Array.isArray(ctx.body.role) ? ctx.body.role : ctx.body.role ? [ctx.body.role] : []; const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); const toBeUpdatedMember = member.id !== ctx.body.memberId ? await adapter.findMemberById(ctx.body.memberId) : member; if (!toBeUpdatedMember) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); if (!(toBeUpdatedMember.organizationId === organizationId)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER }); const creatorRole = ctx.context.orgOptions?.creatorRole || "owner"; const updatingMemberRoles = member.role.split(","); const isUpdatingCreator = toBeUpdatedMember.role.split(",").includes(creatorRole); const updaterIsCreator = updatingMemberRoles.includes(creatorRole); const isSettingCreatorRole = roleToSet.includes(creatorRole); const memberIsUpdatingThemselves = member.id === toBeUpdatedMember.id; if (isUpdatingCreator && !updaterIsCreator || isSettingCreatorRole && !updaterIsCreator) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER }); if (updaterIsCreator && memberIsUpdatingThemselves) { if ((await ctx.context.adapter.findMany({ model: "member", where: [{ field: "organizationId", value: organizationId }] })).filter((member$1) => { return member$1.role.split(",").includes(creatorRole); }).length <= 1 && !isSettingCreatorRole) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER }); } if (!await hasPermission({ role: member.role, options: ctx.context.orgOptions, permissions: { member: ["update"] }, allowCreatorAllPermissions: true, organizationId }, ctx)) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER }); const organization = await adapter.findOrganizationById(organizationId); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); const userBeingUpdated = await ctx.context.internalAdapter.findUserById(toBeUpdatedMember.userId); if (!userBeingUpdated) throw new APIError("BAD_REQUEST", { message: "User not found" }); const previousRole = toBeUpdatedMember.role; const newRole = parseRoles(ctx.body.role); if (option?.organizationHooks?.beforeUpdateMemberRole) { const response = await option?.organizationHooks.beforeUpdateMemberRole({ member: toBeUpdatedMember, newRole, user: userBeingUpdated, organization }); if (response && typeof response === "object" && "data" in response) { const updatedMember$1 = await adapter.updateMember(ctx.body.memberId, response.data.role || newRole); if (!updatedMember$1) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); if (option?.organizationHooks?.afterUpdateMemberRole) await option?.organizationHooks.afterUpdateMemberRole({ member: updatedMember$1, previousRole, user: userBeingUpdated, organization }); return ctx.json(updatedMember$1); } } const updatedMember = await adapter.updateMember(ctx.body.memberId, newRole); if (!updatedMember) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); if (option?.organizationHooks?.afterUpdateMemberRole) await option?.organizationHooks.afterUpdateMemberRole({ member: updatedMember, previousRole, user: userBeingUpdated, organization }); return ctx.json(updatedMember); }); const getActiveMember = (options) => createAuthEndpoint("/organization/get-active-member", { method: "GET", use: [orgMiddleware, orgSessionMiddleware], requireHeaders: true, metadata: { openapi: { description: "Get the member details of the active organization", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, userId: { type: "string" }, organizationId: { type: "string" }, role: { type: "string" } }, required: [ "id", "userId", "organizationId", "role" ] } } } } } } } }, async (ctx) => { const session = ctx.context.session; const organizationId = session.session.activeOrganizationId; if (!organizationId) return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION } }); const member = await getOrgAdapter(ctx.context, options).findMemberByOrgId({ userId: session.user.id, organizationId }); if (!member) return ctx.json(null, { status: 400, body: { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND } }); return ctx.json(member); }); const leaveOrganizationBodySchema = z.object({ organizationId: z.string().meta({ description: "The organization Id for the member to leave. Eg: \"organization-id\"" }) }); const leaveOrganization = (options) => createAuthEndpoint("/organization/leave", { method: "POST", body: leaveOrganizationBodySchema, requireHeaders: true, use: [sessionMiddleware, orgMiddleware] }, async (ctx) => { const session = ctx.context.session; const adapter = getOrgAdapter(ctx.context, options); const member = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId: ctx.body.organizationId }); if (!member) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND }); const creatorRole = ctx.context.orgOptions?.creatorRole || "owner"; if (member.role.split(",").includes(creatorRole)) { if ((await ctx.context.adapter.findMany({ model: "member", where: [{ field: "organizationId", value: ctx.body.organizationId }] })).filter((member$1) => member$1.role.split(",").includes(creatorRole)).length <= 1) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER }); } await adapter.deleteMember({ memberId: member.id, organizationId: ctx.body.organizationId, userId: session.user.id }); if (session.session.activeOrganizationId === ctx.body.organizationId) await adapter.setActiveOrganization(session.session.token, null, ctx); return ctx.json(member); }); const listMembers = (options) => createAuthEndpoint("/organization/list-members", { method: "GET", query: z.object({ limit: z.string().meta({ description: "The number of users to return" }).or(z.number()).optional(), offset: z.string().meta({ description: "The offset to start from" }).or(z.number()).optional(), sortBy: z.string().meta({ description: "The field to sort by" }).optional(), sortDirection: z.enum(["asc", "desc"]).meta({ description: "The direction to sort by" }).optional(), filterField: z.string().meta({ description: "The field to filter by" }).optional(), filterValue: z.string().meta({ description: "The value to filter by" }).or(z.number()).or(z.boolean()).optional(), filterOperator: z.enum([ "eq", "ne", "lt", "lte", "gt", "gte", "contains" ]).meta({ description: "The operator to use for the filter" }).optional(), organizationId: z.string().meta({ description: "The organization ID to list members for. If not provided, will default to the user's active organization. Eg: \"organization-id\"" }).optional(), organizationSlug: z.string().meta({ description: "The organization slug to list members for. If not provided, will default to the user's active organization. Eg: \"organization-slug\"" }).optional() }).optional(), requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware] }, async (ctx) => { const session = ctx.context.session; let organizationId = ctx.query?.organizationId || session.session.activeOrganizationId; const adapter = getOrgAdapter(ctx.context, options); if (ctx.query?.organizationSlug) { const organization = await adapter.findOrganizationBySlug(ctx.query?.organizationSlug); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); organizationId = organization.id; } if (!organizationId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); if (!await adapter.findMemberByOrgId({ userId: session.user.id, organizationId })) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); const { members, total } = await adapter.listMembers({ organizationId, limit: ctx.query?.limit ? Number(ctx.query.limit) : void 0, offset: ctx.query?.offset ? Number(ctx.query.offset) : void 0, sortBy: ctx.query?.sortBy, sortOrder: ctx.query?.sortDirection, filter: ctx.query?.filterField ? { field: ctx.query?.filterField, operator: ctx.query.filterOperator, value: ctx.query.filterValue } : void 0 }); return ctx.json({ members, total }); }); const getActiveMemberRoleQuerySchema = z.object({ userId: z.string().meta({ description: "The user ID to get the role for. If not provided, will default to the current user's" }).optional(), organizationId: z.string().meta({ description: "The organization ID to list members for. If not provided, will default to the user's active organization. Eg: \"organization-id\"" }).optional(), organizationSlug: z.string().meta({ description: "The organization slug to list members for. If not provided, will default to the user's active organization. Eg: \"organization-slug\"" }).optional() }).optional(); const getActiveMemberRole = (options) => createAuthEndpoint("/organization/get-active-member-role", { method: "GET", query: getActiveMemberRoleQuerySchema, requireHeaders: true, use: [orgMiddleware, orgSessionMiddleware] }, async (ctx) => { const session = ctx.context.session; let organizationId = ctx.query?.organizationId || session.session.activeOrganizationId; const adapter = getOrgAdapter(ctx.context, options); if (ctx.query?.organizationSlug) { const organization = await adapter.findOrganizationBySlug(ctx.query?.organizationSlug); if (!organization) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND }); organizationId = organization.id; } if (!organizationId) throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); const isMember = await adapter.findMemberByOrgId({ userId: session.user.id, organizationId }); if (!isMember) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); if (!ctx.query?.userId) return ctx.json({ role: isMember.role }); const userIdToGetRole = ctx.query?.userId; const member = await adapter.findMemberByOrgId({ userId: userIdToGetRole, organizationId }); if (!member) throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); return ctx.json({ role: member?.role }); }); //#endregion export { addMember, getActiveMember, getActiveMemberRole, leaveOrganization, listMembers, removeMember, updateMemberRole }; //# sourceMappingURL=crud-members.mjs.map