mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-25 10:13:54 -05:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user