From 015ba5632b43ad35524f422b44da879ecd58b563 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Sun, 15 Jun 2025 08:14:16 +1000 Subject: [PATCH] feat(organization): maximumMembersPerTeam support (#2374) * feat(organization): maximumMembersPerTeam support Allow configuration of maximum number of members allowed in a team. * chore: cleanup --- docs/content/docs/plugins/organization.mdx | 9 +- .../src/plugins/organization/error-codes.ts | 1 + .../src/plugins/organization/organization.ts | 16 +++ .../organization/routes/crud-invites.ts | 68 +++++++++++++ .../src/plugins/organization/team.test.ts | 99 +++++++++++++++++++ 5 files changed, 192 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 959f9572a6..35fc8315b8 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -973,7 +973,14 @@ The teams feature supports several configuration options: // Dynamic limit based on organization plan const plan = await getPlan(organizationId) return plan === 'pro' ? 20 : 5 - } + }, + maximumMembersPerTeam: 10 // Fixed number + // OR + maximumMembersPerTeam: async ({ teamId, session, organizationId }, request) => { + // Dynamic limit based on team plan + const plan = await getPlan(organizationId, teamId) + return plan === 'pro' ? 50 : 10 + }, } ``` diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts index 45a2da1439..2a8cf30467 100644 --- a/packages/better-auth/src/plugins/organization/error-codes.ts +++ b/packages/better-auth/src/plugins/organization/error-codes.ts @@ -54,4 +54,5 @@ export const ORGANIZATION_ERROR_CODES = { YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM: "You are not allowed to delete this team", INVITATION_LIMIT_REACHED: "Invitation limit reached", + TEAM_MEMBER_LIMIT_REACHED: "Team member limit reached", } as const; diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts index 97a7601dcd..1764d78611 100644 --- a/packages/better-auth/src/plugins/organization/organization.ts +++ b/packages/better-auth/src/plugins/organization/organization.ts @@ -151,6 +151,22 @@ export interface OrganizationOptions { request?: Request, ) => number | Promise) | number; + + /** + * The maximum number of members per team. + * + * if `undefined`, there is no limit. + * + * @default undefined + */ + maximumMembersPerTeam?: + | number + | ((data: { + teamId: string; + session: { user: User; session: Session }; + organizationId: string; + }) => Promise | number) + | undefined; /** * By default, if an organization does only have one team, they'll not be able to remove it. * 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 52bb7b4830..8f51d4e58f 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -236,6 +236,40 @@ export const createInvitation = ( }); } + 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 team = await adapter.findTeamById({ + teamId: ctx.body.teamId, + organizationId: 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: ctx.body.teamId, + session: session, + organizationId: organizationId, + }) + : ctx.context.orgOptions.teams.maximumMembersPerTeam; + if (team.members.length >= maximumMembersPerTeam) { + throw new APIError("FORBIDDEN", { + message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, + }); + } + } + const invitation = await adapter.createInvitation({ invitation: { role: roles, @@ -342,6 +376,40 @@ export const acceptInvitation = createAuthEndpoint( message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION, }); } + + if ( + ctx.context.orgOptions.teams && + ctx.context.orgOptions.teams.enabled && + typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== + "undefined" && + "teamId" in acceptedI && + acceptedI.teamId + ) { + const team = await adapter.findTeamById({ + teamId: acceptedI.teamId, + organizationId: invitation.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: acceptedI.teamId, + session: session, + organizationId: invitation.organizationId, + }) + : ctx.context.orgOptions.teams.maximumMembersPerTeam; + if (team.members.length >= maximumMembersPerTeam) { + throw new APIError("FORBIDDEN", { + message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, + }); + } + } + const member = await adapter.createMember({ organizationId: invitation.organizationId, userId: session.user.id, diff --git a/packages/better-auth/src/plugins/organization/team.test.ts b/packages/better-auth/src/plugins/organization/team.test.ts index cac0076231..8a5c04b21f 100644 --- a/packages/better-auth/src/plugins/organization/team.test.ts +++ b/packages/better-auth/src/plugins/organization/team.test.ts @@ -213,4 +213,103 @@ describe("team", async (it) => { expect(error).toBeDefined(); } }); + + it("should not be allowed to invite a member to a team that's reached maximum members", async () => { + const { auth, signInWithTestUser } = await getTestInstance({ + user: { + modelName: "users", + }, + plugins: [ + organization({ + teams: { + enabled: true, + maximumMembersPerTeam: 1, + }, + }), + ], + logger: { + level: "error", + }, + }); + + const { headers } = await signInWithTestUser(); + const client = createAuthClient({ + plugins: [ + organizationClient({ + teams: { + enabled: true, + }, + }), + ], + baseURL: "http://localhost:3000/api/auth", + fetchOptions: { + customFetchImpl: async (url, init) => { + return auth.handler(new Request(url, init)); + }, + }, + }); + const createOrganizationResponse = await client.organization.create({ + name: "Test Organization", + slug: "test-org", + metadata: { + test: "organization-metadata", + }, + fetchOptions: { + headers, + }, + }); + expect(createOrganizationResponse.data?.id).toBeDefined(); + + const createTeamResponse = await client.organization.createTeam( + { + name: "Development Team", + organizationId: createOrganizationResponse.data?.id, + }, + { + headers, + }, + ); + expect(createTeamResponse.data?.id).toBeDefined(); + + const res = await client.organization.inviteMember( + { + teamId: createTeamResponse.data?.id, + email: invitedUser.email, + role: "member", + }, + { + headers, + }, + ); + expect(res.data).toBeDefined(); + const newHeaders = new Headers(); + const signUpRes = await client.signUp.email(invitedUser, { + onSuccess: cookieSetter(newHeaders), + }); + + expect(signUpRes.data?.user).toBeDefined(); + + const acceptInvitationResponse = await client.organization.acceptInvitation( + { + invitationId: res.data?.id as string, + }, + { + headers: newHeaders, + }, + ); + expect(acceptInvitationResponse.data).toBeDefined(); + + const res2 = await client.organization.inviteMember( + { + teamId: createTeamResponse.data?.id, + email: "test2@test.com", + role: "member", + }, + { + headers, + }, + ); + expect(res2.data).toBeNull(); + expect(res2.error?.code).toEqual("TEAM_MEMBER_LIMIT_REACHED"); + }); });