feat(organization): maximumMembersPerTeam support (#2374)

* feat(organization): maximumMembersPerTeam support

Allow configuration of maximum number of members allowed in a team.

* chore: cleanup
This commit is contained in:
Maxwell
2025-06-14 15:14:16 -07:00
committed by GitHub
parent 475472c971
commit 015ba5632b
5 changed files with 192 additions and 1 deletions
+8 -1
View File
@@ -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
},
}
```
@@ -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;
@@ -151,6 +151,22 @@ export interface OrganizationOptions {
request?: Request,
) => number | Promise<number>)
| 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> | number)
| undefined;
/**
* By default, if an organization does only have one team, they'll not be able to remove it.
*
@@ -236,6 +236,40 @@ export const createInvitation = <O extends OrganizationOptions | undefined>(
});
}
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,
@@ -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");
});
});