diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts index 92190b0011..6721cf7822 100644 --- a/packages/better-auth/src/plugins/organization/adapter.ts +++ b/packages/better-auth/src/plugins/organization/adapter.ts @@ -129,6 +129,7 @@ export const getOrgAdapter = ( value: data.organizationId, }, ], + limit: options?.membershipLimit || 100, }); return members; }, @@ -356,6 +357,7 @@ export const getOrgAdapter = ( adapter.findMany({ model: "member", where: [{ field: "organizationId", value: org.id }], + limit: options?.membershipLimit || 100, }), includeTeams ? adapter.findMany({ @@ -408,6 +410,7 @@ export const getOrgAdapter = ( value: userId, }, ], + limit: options?.membershipLimit || 100, }); if (!members || members.length === 0) { diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts index e9cf4a7573..ce4c03dff1 100644 --- a/packages/better-auth/src/plugins/organization/error-codes.ts +++ b/packages/better-auth/src/plugins/organization/error-codes.ts @@ -43,4 +43,6 @@ export const ORGANIZATION_ERROR_CODES = { UNABLE_TO_REMOVE_LAST_TEAM: "Unable to remove last team", YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER: "You are not allowed to update this member", + ORGANIZATION_MEMBERSHIP_LIMIT_REACHED: + "Organization membership limit reached", } as const; diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts index 98e0e1c49a..527e4182af 100644 --- a/packages/better-auth/src/plugins/organization/organization.test.ts +++ b/packages/better-auth/src/plugins/organization/organization.test.ts @@ -6,6 +6,7 @@ import { organizationClient } from "./client"; import { createAccessControl } from "../access"; import { ORGANIZATION_ERROR_CODES } from "./error-codes"; import { BetterAuthError } from "../../error"; +import { APIError } from "better-call"; describe("organization", async (it) => { const { auth, signInWithTestUser, signInWithUser, cookieSetter } = @@ -15,6 +16,7 @@ describe("organization", async (it) => { }, plugins: [ organization({ + membershipLimit: 6, async sendInvitationEmail(data, request) {}, schema: { organization: { @@ -525,6 +527,120 @@ describe("organization", async (it) => { }); expect(member?.role).toBe("admin"); }); + + it("should respect membershipLimit when adding members to organization", async () => { + const org = await auth.api.createOrganization({ + body: { + name: "test-5-membership-limit", + slug: "test-5-membership-limit", + }, + headers, + }); + + const users = [ + "user1@emial.com", + "user2@email.com", + "user3@email.com", + "user4@email.com", + ]; + + for (const user of users) { + const newUser = await auth.api.signUpEmail({ + body: { + email: user, + password: "password", + name: user, + }, + }); + const session = await auth.api.getSession({ + headers: new Headers({ + Authorization: `Bearer ${newUser?.token}`, + }), + }); + await auth.api.addMember({ + body: { + organizationId: org?.id, + userId: session?.user.id!, + role: "admin", + }, + }); + } + + const userOverLimit = { + email: "shouldthrowerror@email.com", + password: "password", + name: "name", + }; + + // test api method + const newUser = await auth.api.signUpEmail({ + body: { + email: userOverLimit.email, + password: userOverLimit.password, + name: userOverLimit.name, + }, + }); + const session = await auth.api.getSession({ + headers: new Headers({ + Authorization: `Bearer ${newUser?.token}`, + }), + }); + await auth.api + .addMember({ + body: { + organizationId: org?.id, + userId: session?.user.id!, + role: "admin", + }, + }) + .catch((e: APIError) => { + expect(e).not.toBeNull(); + expect(e).toBeInstanceOf(APIError); + expect(e.message).toBe( + ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, + ); + }); + + // test client method + const invite = await client.organization.inviteMember({ + organizationId: org?.id, + email: userOverLimit.email, + role: "member", + fetchOptions: { + headers, + }, + }); + if (!invite.data) throw new Error("Invitation not created"); + await client.signUp.email({ + email: userOverLimit.email, + password: userOverLimit.password, + name: userOverLimit.name, + }); + const { res, headers: headers2 } = await signInWithUser( + userOverLimit.email, + userOverLimit.password, + ); + + const invitation = await client.organization.acceptInvitation({ + invitationId: invite.data.id, + fetchOptions: { + headers: headers2, + }, + }); + expect(invitation.error?.message).toBe( + ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, + ); + + const getFullOrganization = await client.organization.getFullOrganization({ + query: { + organizationId: org?.id, + }, + fetchOptions: { + headers, + }, + }); + expect(getFullOrganization.data?.members.length).toBe(6); + }); }); describe("access control", async (it) => { diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index b1b228c6aa..68d607bb74 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -83,7 +83,7 @@ export interface OrganizationOptions { /** * The number of memberships a user can have in an organization. * - * @default "unlimited" + * @default 100 */ membershipLimit?: number; /** diff --git a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts index a698e753e3..4c7db38184 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -290,6 +290,16 @@ export const acceptInvitation = createAuthEndpoint( ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION, }); } + const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; + const members = await adapter.listMembers({ + organizationId: invitation.organizationId, + }); + + if (members.length >= membershipLimit) { + throw new APIError("FORBIDDEN", { + message: ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, + }); + } const acceptedI = await adapter.updateInvitation({ invitationId: ctx.body.invitationId, status: "accepted", diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts index 40ac2c608b..01137d5119 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -64,6 +64,7 @@ export const addMember = () => email: user.email, organizationId: orgId, }); + if (alreadyMember) { throw new APIError("BAD_REQUEST", { message: @@ -71,6 +72,16 @@ export const addMember = () => }); } + const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; + const members = await adapter.listMembers({ organizationId: orgId }); + + if (members.length >= membershipLimit) { + throw new APIError("FORBIDDEN", { + message: + ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, + }); + } + const createdMember = await adapter.createMember({ id: generateId(), organizationId: orgId,