diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx
index bfa1b374ab..4d77f3e9e6 100644
--- a/docs/content/docs/plugins/organization.mdx
+++ b/docs/content/docs/plugins/organization.mdx
@@ -1151,6 +1151,80 @@ type removeTeam = {
```
+#### Set Active Team
+Sets the given team as the current active team. If `teamId` is `null` the current active team is unset.
+
+
+```ts
+type setActiveTeam = {
+ /**
+ * The team ID of the team to set as the current active team.
+ */
+ teamId?: string = "team-id"
+}
+```
+
+
+#### List User Teams
+List all teams that the current user is a part of.
+
+
+```ts
+type listUserTeams = {
+}
+```
+
+
+#### List Team Members
+List the members of the given team.
+
+
+```ts
+type listTeamMembers = {
+ /**
+ * The team whose members we should return. If this is not provided the members of the current active team get returned.
+ */
+ teamId?: string = "team-id"
+}
+```
+
+
+#### Add Team Member
+Add a member to a team.
+
+
+```ts
+type addTeamMember = {
+ /**
+ * The team the user should be a member of.
+ */
+ teamId: string = "team-id"
+ /**
+ * The user Id which represents the user to be added as a member.
+ */
+ userId: string = "user-id"
+}
+```
+
+
+#### Remove Team Member
+Remove a member from a team.
+
+
+```ts
+type removeTeamMember = {
+ /**
+ * The team the user should be removed from.
+ */
+ teamId: string = "team-id"
+ /**
+ * The user which should be removed from the team.
+ */
+ userId: string = "user-id"
+}
+```
+
+
### Team Permissions
Teams follow the organization's permission system. To manage teams, users need the following permissions:
@@ -1212,7 +1286,9 @@ The invited member will be added to the specified team upon accepting the invita
### Database Schema
-When teams are enabled, a new `team` table is added with the following structure:
+When teams are enabled, new `team` and `teamMember` tables are added to the database.
+
+Table Name: `team`
+
+Table Name: `teamMember`
+
+
@@ -1385,7 +1492,7 @@ Table Name: `invitation`
Table Name: `session`
-You need to add one more field to the session table to store the active organization ID.
+You need to add two more fields to the session table to store the active organization ID and the active team ID.
@@ -1435,20 +1548,37 @@ Table Name: `team`
]}
/>
- Table Name: `member`
+Table Name: `teamMember`
- Table Name: `invitation`
+Table Name: `invitation`
(
includeTeamMembers?: IncludeMembers;
}): Promise<
| (InferTeam &
- (IncludeMembers extends true ? { members: InferMember[] } : {}))
+ (IncludeMembers extends true ? { members: TeamMember[] } : {}))
| null
> => {
const team = await adapter.findOne>({
@@ -467,10 +468,11 @@ export const getOrgAdapter = (
if (!team) {
return null;
}
- let members: InferMember[] = [];
+
+ let members: TeamMember[] = [];
if (includeTeamMembers) {
- members = await adapter.findMany>({
- model: "member",
+ members = await adapter.findMany({
+ model: "teamMember",
where: [
{
field: "teamId",
@@ -484,8 +486,9 @@ export const getOrgAdapter = (
members,
};
}
+
return team as InferTeam &
- (IncludeMembers extends true ? { members: InferMember[] } : {});
+ (IncludeMembers extends true ? { members: TeamMember[] } : {});
},
updateTeam: async (
teamId: string,
@@ -570,6 +573,130 @@ export const getOrgAdapter = (
return invitation;
},
+
+ setActiveTeam: async (sessionToken: string, teamId: string | null) => {
+ const session = await context.internalAdapter.updateSession(
+ sessionToken,
+ {
+ activeTeamId: teamId,
+ },
+ );
+ return session as Session;
+ },
+
+ listTeamMembers: async (data: {
+ teamId: string;
+ }) => {
+ const members = await adapter.findMany({
+ model: "teamMember",
+ where: [
+ {
+ field: "teamId",
+ value: data.teamId,
+ },
+ ],
+ });
+
+ return members;
+ },
+
+ listTeamsByUser: async (data: {
+ userId: string;
+ }) => {
+ const members = await adapter.findMany({
+ model: "teamMember",
+ where: [
+ {
+ field: "userId",
+ value: data.userId,
+ },
+ ],
+ });
+
+ const teams = await adapter.findMany({
+ model: "team",
+ where: [
+ {
+ field: "id",
+ operator: "in",
+ value: members.map((m) => m.teamId),
+ },
+ ],
+ });
+
+ return teams;
+ },
+
+ findTeamMember: async (data: {
+ teamId: string;
+ userId: string;
+ }) => {
+ const member = await adapter.findOne({
+ model: "teamMember",
+ where: [
+ {
+ field: "teamId",
+ value: data.teamId,
+ },
+ {
+ field: "userId",
+ value: data.userId,
+ },
+ ],
+ });
+
+ return member;
+ },
+
+ findOrCreateTeamMember: async (data: {
+ teamId: string;
+ userId: string;
+ }) => {
+ const member = await adapter.findOne({
+ model: "teamMember",
+ where: [
+ {
+ field: "teamId",
+ value: data.teamId,
+ },
+ {
+ field: "userId",
+ value: data.userId,
+ },
+ ],
+ });
+
+ if (member) return member;
+
+ return await adapter.create, TeamMember>({
+ model: "teamMember",
+ data: {
+ teamId: data.teamId,
+ userId: data.userId,
+ createdAt: new Date(),
+ },
+ });
+ },
+
+ removeTeamMember: async (data: {
+ teamId: string;
+ userId: string;
+ }) => {
+ await adapter.delete({
+ model: "teamMember",
+ where: [
+ {
+ field: "teamId",
+ value: data.teamId,
+ },
+ {
+ field: "userId",
+ value: data.userId,
+ },
+ ],
+ });
+ },
+
findInvitationsByTeamId: async (teamId: string) => {
const invitations = await adapter.findMany>({
model: "invitation",
@@ -597,7 +724,7 @@ export const getOrgAdapter = (
email: string;
role: string;
organizationId: string;
- teamId?: string;
+ teamIds: string[];
} & Record; // This represents the additionalFields for the invitation
user: User;
}) => {
@@ -606,6 +733,7 @@ export const getOrgAdapter = (
options?.invitationExpiresIn || defaultExpiration,
"sec",
);
+
const invite = await adapter.create<
Omit,
InferInvitation
@@ -616,6 +744,7 @@ export const getOrgAdapter = (
expiresAt,
inviterId: user.id,
...invitation,
+ teamId: invitation.teamIds.join(","),
},
});
diff --git a/packages/better-auth/src/plugins/organization/call.ts b/packages/better-auth/src/plugins/organization/call.ts
index 96bcb4d76e..0f71573dc9 100644
--- a/packages/better-auth/src/plugins/organization/call.ts
+++ b/packages/better-auth/src/plugins/organization/call.ts
@@ -5,7 +5,7 @@ import type { Role } from "../access";
import type { OrganizationOptions } from "./types";
import type { defaultRoles } from "./access/statement";
-export const orgMiddleware = createAuthMiddleware(async (ctx) => {
+export const orgMiddleware = createAuthMiddleware(async () => {
return {} as {
orgOptions: OrganizationOptions;
roles: typeof defaultRoles & {
@@ -13,6 +13,7 @@ export const orgMiddleware = createAuthMiddleware(async (ctx) => {
};
getSession: (context: GenericEndpointContext) => Promise<{
session: Session & {
+ activeTeamId?: string;
activeOrganizationId?: string;
};
user: User;
@@ -31,6 +32,7 @@ export const orgSessionMiddleware = createAuthMiddleware(
async (ctx) => {
const session = ctx.context.session as {
session: Session & {
+ activeTeamId?: string;
activeOrganizationId?: string;
};
user: User;
diff --git a/packages/better-auth/src/plugins/organization/error-codes.ts b/packages/better-auth/src/plugins/organization/error-codes.ts
index 2a8cf30467..550fe96122 100644
--- a/packages/better-auth/src/plugins/organization/error-codes.ts
+++ b/packages/better-auth/src/plugins/organization/error-codes.ts
@@ -55,4 +55,12 @@ export const ORGANIZATION_ERROR_CODES = {
"You are not allowed to delete this team",
INVITATION_LIMIT_REACHED: "Invitation limit reached",
TEAM_MEMBER_LIMIT_REACHED: "Team member limit reached",
+ USER_IS_NOT_A_MEMBER_OF_THE_TEAM: "User is not a member of the team",
+ YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM:
+ "You are not allowed to list the members of this team",
+ YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM: "You do not have an active team",
+ YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER:
+ "You are not allowed to create a new member",
+ YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER:
+ "You are not allowed to remove a team member",
} 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 76caf37cde..d4154e2239 100644
--- a/packages/better-auth/src/plugins/organization/organization.test.ts
+++ b/packages/better-auth/src/plugins/organization/organization.test.ts
@@ -1327,6 +1327,9 @@ describe("Additional Fields", async () => {
teamRequiredField: string;
teamOptionalField?: string;
}[],
+ teamMember: [] as {
+ id: string;
+ }[],
};
const orgOptions = {
@@ -1429,7 +1432,6 @@ describe("Additional Fields", async () => {
userId: string;
role: string;
createdAt: Date;
- teamId?: string | undefined;
} & {
memberRequiredField: string;
} & {
diff --git a/packages/better-auth/src/plugins/organization/organization.ts b/packages/better-auth/src/plugins/organization/organization.ts
index ecf55a5791..d235af460b 100644
--- a/packages/better-auth/src/plugins/organization/organization.ts
+++ b/packages/better-auth/src/plugins/organization/organization.ts
@@ -39,12 +39,18 @@ import {
listOrganizationTeams,
removeTeam,
updateTeam,
+ setActiveTeam,
+ listUserTeams,
+ listTeamMembers,
+ addTeamMember,
+ removeTeamMember,
} from "./routes/crud-team";
import type {
InferInvitation,
InferMember,
InferOrganization,
Team,
+ TeamMember,
} from "./schema";
import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { defaultRoles, defaultStatements } from "./access";
@@ -431,6 +437,86 @@ export const organization = (
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-update-team)
*/
updateTeam: updateTeam(options as O),
+ /**
+ * ### Endpoint
+ *
+ * POST `/organization/set-active-team`
+ *
+ * ### API Methods
+ *
+ * **server:**
+ * `auth.api.setActiveTeam`
+ *
+ * **client:**
+ * `authClient.organization.setActiveTeam`
+ *
+ * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
+ */
+ setActiveTeam: setActiveTeam(options as O),
+ /**
+ * ### Endpoint
+ *
+ * POST `/organization/list-user-teams`
+ *
+ * ### API Methods
+ *
+ * **server:**
+ * `auth.api.listUserTeams`
+ *
+ * **client:**
+ * `authClient.organization.listUserTeams`
+ *
+ * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
+ */
+ listUserTeams: listUserTeams(options as O),
+ /**
+ * ### Endpoint
+ *
+ * POST `/organization/list-team-members`
+ *
+ * ### API Methods
+ *
+ * **server:**
+ * `auth.api.listTeamMembers`
+ *
+ * **client:**
+ * `authClient.organization.listTeamMembers`
+ *
+ * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team)
+ */
+ listTeamMembers: listTeamMembers(options as O),
+ /**
+ * ### Endpoint
+ *
+ * POST `/organization/add-team-member`
+ *
+ * ### API Methods
+ *
+ * **server:**
+ * `auth.api.addTeamMember`
+ *
+ * **client:**
+ * `authClient.organization.addTeamMember`
+ *
+ * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-add-team-member)
+ */
+ addTeamMember: addTeamMember(options as O),
+ /**
+ * ### Endpoint
+ *
+ * POST `/organization/remove-team-member`
+ *
+ * ### API Methods
+ *
+ * **server:**
+ * `auth.api.removeTeamMember`
+ *
+ * **client:**
+ * `authClient.organization.removeTeamMember`
+ *
+ * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-remove-team-member)
+ */
+ removeTeamMember: removeTeamMember(options as O),
};
if (teamSupport) {
endpoints = {
@@ -475,6 +561,30 @@ export const organization = (
...(options?.schema?.team?.additionalFields || {}),
},
},
+ teamMember: {
+ modelName: options?.schema?.teamMember?.modelName,
+ fields: {
+ teamId: {
+ type: "string",
+ required: true,
+ fieldName: options?.schema?.teamMember?.fields?.teamId,
+ },
+ userId: {
+ type: "string",
+ required: true,
+ references: {
+ model: "user",
+ field: "id",
+ },
+ fieldName: options?.schema?.teamMember?.fields?.userId,
+ },
+ createdAt: {
+ type: "date",
+ required: false,
+ fieldName: options?.schema?.teamMember?.fields?.createdAt,
+ },
+ },
+ },
} satisfies AuthPluginSchema)
: undefined;
@@ -637,6 +747,15 @@ export const organization = (
required: false,
fieldName: options?.schema?.session?.fields?.activeOrganizationId,
},
+ ...(teamSupport
+ ? {
+ activeTeamId: {
+ type: "string",
+ required: false,
+ fieldName: options?.schema?.session?.fields?.activeTeamId,
+ },
+ }
+ : {}),
},
},
organization: {
@@ -700,16 +819,6 @@ export const organization = (
defaultValue: "member",
fieldName: options?.schema?.member?.fields?.role,
},
- ...(teamSupport
- ? {
- teamId: {
- type: "string",
- required: false,
- sortable: true,
- fieldName: options?.schema?.member?.fields?.teamId,
- },
- }
- : {}),
createdAt: {
type: "date",
required: true,
@@ -783,6 +892,7 @@ export const organization = (
Invitation: {} as InferInvitation,
Member: {} as InferMember,
Team: teamSupport ? ({} as Team) : ({} as any),
+ TeamMember: teamSupport ? ({} as TeamMember) : ({} as any),
ActiveOrganization: {} as Awaited<
ReturnType>>
>,
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 f07a2f7c9a..f420b0e547 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts
@@ -9,6 +9,7 @@ import { parseRoles } from "../organization";
import { type OrganizationOptions } from "../types";
import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import { hasPermission } from "../has-permission";
+import { setSessionCookie } from "../../../cookies";
import {
toZodSchema,
type InferAdditionalFieldsFromPluginOptions,
@@ -19,6 +20,7 @@ export const createInvitation = (option: O) => {
fields: option?.schema?.invitation?.additionalFields || {},
isClientSide: true,
});
+
const baseSchema = z.object({
email: z.string().meta({
description: "The email address of the user to invite",
@@ -51,12 +53,21 @@ export const createInvitation = (option: O) => {
"Resend the invitation email, if the user is already invited. Eg: true",
})
.optional(),
- teamId: z
- .string()
- .meta({
- description: "The team ID to invite the user to",
- })
- .optional(),
+ teamId: z.union([
+ z
+ .string()
+ .meta({
+ description: "The team ID to invite the user to",
+ })
+ .optional(),
+ z
+ .array(
+ z.string().meta({
+ description: "The team ID to invite the user to",
+ }),
+ )
+ .optional(),
+ ]),
});
return createAuthEndpoint(
@@ -98,7 +109,7 @@ export const createInvitation = (option: O) => {
* The team the user is
* being invited to.
*/
- teamId?: string;
+ teamId?: string | string[];
}
: {}) &
InferAdditionalFieldsFromPluginOptions<"invitation", O, false>,
@@ -265,33 +276,48 @@ export const createInvitation = (option: O) => {
"teamId" in ctx.body &&
ctx.body.teamId
) {
- const teamId = ctx.body.teamId as string;
- const team = await adapter.findTeamById({
- 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,
- 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 teamIds =
+ typeof ctx.body.teamId === "string"
+ ? [ctx.body.teamId as string]
+ : (ctx.body.teamId as string[]);
+
+ for (const teamId of teamIds) {
+ const team = await adapter.findTeamById({
+ 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,
+ 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 teamIds: string[] =
+ "teamId" in ctx.body
+ ? typeof ctx.body.teamId === "string"
+ ? [ctx.body.teamId as string]
+ : (ctx.body.teamId as string[]) ?? []
+ : [];
+
const {
email: _,
role: __,
@@ -305,11 +331,7 @@ export const createInvitation = (option: O) => {
role: roles,
email: ctx.body.email.toLowerCase(),
organizationId: organizationId,
- ...("teamId" in ctx.body
- ? {
- teamId: ctx.body.teamId as string,
- }
- : {}),
+ teamIds,
...(additionalFields ? additionalFields : {}),
},
user: session.user,
@@ -330,6 +352,7 @@ export const createInvitation = (option: O) => {
},
ctx.request,
);
+
return ctx.json(invitation);
},
);
@@ -378,6 +401,7 @@ export const acceptInvitation = (options: O) =>
const invitation = await adapter.findInvitationById(
ctx.body.invitationId,
);
+
if (
!invitation ||
invitation.expiresAt < new Date() ||
@@ -387,12 +411,14 @@ export const acceptInvitation = (options: O) =>
message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND,
});
}
+
if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) {
throw new APIError("FORBIDDEN", {
message:
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,
@@ -408,6 +434,7 @@ export const acceptInvitation = (options: O) =>
invitationId: ctx.body.invitationId,
status: "accepted",
});
+
if (!acceptedI) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION,
@@ -417,34 +444,52 @@ export const acceptInvitation = (options: O) =>
if (
ctx.context.orgOptions.teams &&
ctx.context.orgOptions.teams.enabled &&
- typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !==
- "undefined" &&
"teamId" in acceptedI &&
acceptedI.teamId
) {
- const teamId = acceptedI.teamId as string;
- const team = await adapter.findTeamById({
- teamId,
- organizationId: invitation.organizationId,
- includeTeamMembers: true,
- });
- if (!team) {
- throw new APIError("BAD_REQUEST", {
- message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
+ const teamIds = (acceptedI.teamId as string).split(",");
+ const onlyOne = teamIds.length === 1;
+
+ for (const teamId of teamIds) {
+ await adapter.findOrCreateTeamMember({
+ teamId: teamId,
+ userId: session.user.id,
});
+
+ if (
+ typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !==
+ "undefined"
+ ) {
+ const members = await adapter.listTeamMembers({ teamId });
+
+ const maximumMembersPerTeam =
+ typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
+ "function"
+ ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
+ teamId,
+ session: session,
+ organizationId: invitation.organizationId,
+ })
+ : ctx.context.orgOptions.teams.maximumMembersPerTeam;
+
+ if (members.length >= maximumMembersPerTeam) {
+ throw new APIError("FORBIDDEN", {
+ message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED,
+ });
+ }
+ }
}
- const maximumMembersPerTeam =
- typeof ctx.context.orgOptions.teams.maximumMembersPerTeam ===
- "function"
- ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({
- 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,
+
+ if (onlyOne) {
+ const teamId = teamIds[0];
+ const updatedSession = await adapter.setActiveTeam(
+ session.session.token,
+ teamId,
+ );
+
+ await setSessionCookie(ctx, {
+ session: updatedSession,
+ user: session.user,
});
}
}
@@ -454,16 +499,13 @@ export const acceptInvitation = (options: O) =>
userId: session.user.id,
role: invitation.role as string,
createdAt: new Date(),
- ...("teamId" in acceptedI
- ? {
- teamId: acceptedI.teamId as string,
- }
- : {}),
});
+
await adapter.setActiveOrganization(
session.session.token,
invitation.organizationId,
);
+
if (!acceptedI) {
return ctx.json(null, {
status: 400,
@@ -472,6 +514,7 @@ export const acceptInvitation = (options: O) =>
},
});
}
+
return ctx.json({
invitation: acceptedI,
member,
@@ -518,7 +561,7 @@ export const rejectInvitation = (options: O) =>
},
async (ctx) => {
const session = ctx.context.session;
- const adapter = getOrgAdapter(ctx.context, options);
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
const invitation = await adapter.findInvitationById(
ctx.body.invitationId,
);
@@ -537,10 +580,12 @@ export const rejectInvitation = (options: O) =>
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION,
});
}
+
const rejectedI = await adapter.updateInvitation({
invitationId: ctx.body.invitationId,
status: "rejected",
});
+
return ctx.json({
invitation: rejectedI,
member: null,
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 7c05439275..c096a66296 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts
@@ -153,9 +153,15 @@ export const addMember = (option: O) => {
role: parseRoles(ctx.body.role as string | string[]),
createdAt: new Date(),
...(additionalFields ? additionalFields : {}),
- ...(teamId ? { teamId } : {}),
});
+ if (teamId) {
+ await adapter.findOrCreateTeamMember({
+ userId: user.id,
+ teamId,
+ });
+ }
+
return ctx.json(createdMember);
},
);
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-org.ts b/packages/better-auth/src/plugins/organization/routes/crud-org.ts
index 9c6cea4d4e..8af9277c0f 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-org.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-org.ts
@@ -13,6 +13,7 @@ import type {
InferOrganization,
Member,
Team,
+ TeamMember,
} from "../schema";
import { hasPermission } from "../has-permission";
import {
@@ -166,6 +167,7 @@ export const createOrganization = (
data: Record;
}
| undefined = undefined;
+
if (options.organizationCreation?.beforeCreate) {
const response = await options.organizationCreation.beforeCreate(
{
@@ -189,9 +191,12 @@ export const createOrganization = (
...(hookResponse?.data || {}),
},
});
+
let member:
| (Member & InferAdditionalFieldsFromPluginOptions<"member", O, false>)
| undefined;
+ let teamMember: TeamMember | null = null;
+
if (
options?.teams?.enabled &&
options.teams.defaultTeam?.enabled !== false
@@ -208,11 +213,14 @@ export const createOrganization = (
}));
member = await adapter.createMember({
- teamId: defaultTeam.id,
userId: user.id,
organizationId: organization.id,
role: ctx.context.orgOptions.creatorRole || "owner",
- createdAt: new Date(),
+ });
+
+ teamMember = await adapter.findOrCreateTeamMember({
+ teamId: defaultTeam.id,
+ userId: user.id,
});
} else {
member = await adapter.createMember({
@@ -240,6 +248,17 @@ export const createOrganization = (
);
}
+ if (
+ teamMember &&
+ ctx.context.session &&
+ !ctx.body.keepCurrentActiveOrganization
+ ) {
+ await adapter.setActiveTeam(
+ ctx.context.session.session.token,
+ teamMember.teamId,
+ );
+ }
+
return ctx.json({
...organization,
metadata: ctx.body.metadata,
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-team.ts b/packages/better-auth/src/plugins/organization/routes/crud-team.ts
index 5ad48f2772..75de279fc2 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-team.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-team.ts
@@ -8,6 +8,7 @@ import { ORGANIZATION_ERROR_CODES } from "../error-codes";
import type { OrganizationOptions } from "../types";
import { teamSchema } from "../schema";
import { hasPermission } from "../has-permission";
+import { setSessionCookie } from "../../../cookies";
import {
toZodSchema,
type InferAdditionalFieldsFromPluginOptions,
@@ -232,7 +233,7 @@ export const removeTeam = (options: O) =>
organizationId,
});
- if (!member || member.teamId === ctx.body.teamId) {
+ if (!member || session.session?.activeTeamId === ctx.body.teamId) {
throw new APIError("FORBIDDEN", {
message:
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM,
@@ -522,7 +523,483 @@ export const listOrganizationTeams = (
}
const teams = await adapter.listTeams(organizationId);
+ return ctx.json(teams);
+ },
+ );
+
+export const setActiveTeam = (options: O) =>
+ createAuthEndpoint(
+ "/organization/set-active-team",
+ {
+ method: "POST",
+ body: z.object({
+ teamId: z
+ .string()
+ .meta({
+ description:
+ "The team id to set as active. It can be null to unset the active team",
+ })
+ .nullable()
+ .optional(),
+ }),
+ use: [orgSessionMiddleware, orgMiddleware],
+ metadata: {
+ openapi: {
+ description: "Set the active team",
+ responses: {
+ "200": {
+ description: "Success",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ description: "The team",
+ $ref: "#/components/schemas/Team",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ async (ctx) => {
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+ const session = ctx.context.session;
+
+ if (ctx.body.teamId === null) {
+ const sessionTeamId = session.session.activeTeamId;
+ if (!sessionTeamId) {
+ return ctx.json(null);
+ }
+
+ const updatedSession = await adapter.setActiveTeam(
+ session.session.token,
+ null,
+ );
+
+ await setSessionCookie(ctx, {
+ session: updatedSession,
+ user: session.user,
+ });
+
+ return ctx.json(null);
+ }
+
+ let teamId: string;
+
+ if (!ctx.body.teamId) {
+ const sessionTeamId = session.session.activeTeamId;
+ if (!sessionTeamId) {
+ return ctx.json(null);
+ } else {
+ teamId = sessionTeamId;
+ }
+ } else {
+ teamId = ctx.body.teamId;
+ }
+
+ const team = await adapter.findTeamById({ teamId });
+
+ if (!team) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
+ });
+ }
+
+ const member = await adapter.findTeamMember({
+ teamId,
+ userId: session.user.id,
+ });
+
+ if (!member) {
+ throw new APIError("FORBIDDEN", {
+ message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
+ });
+ }
+
+ const updatedSession = await adapter.setActiveTeam(
+ session.session.token,
+ team.id,
+ );
+
+ await setSessionCookie(ctx, {
+ session: updatedSession,
+ user: session.user,
+ });
+
+ return ctx.json(team);
+ },
+ );
+
+export const listUserTeams = (options: O) =>
+ createAuthEndpoint(
+ "/organization/list-user-teams",
+ {
+ method: "GET",
+ metadata: {
+ openapi: {
+ description: "List all teams that the current user is a part of.",
+ responses: {
+ "200": {
+ description: "Teams retrieved successfully",
+ content: {
+ "application/json": {
+ schema: {
+ type: "array",
+ items: {
+ type: "object",
+ description: "The team",
+ $ref: "#/components/schemas/Team",
+ },
+ description:
+ "Array of team objects within the organization",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ use: [orgMiddleware, orgSessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+ const teams = await adapter.listTeamsByUser({
+ userId: session.user.id,
+ });
return ctx.json(teams);
},
);
+
+export const listTeamMembers = (options: O) =>
+ createAuthEndpoint(
+ "/organization/list-team-members",
+ {
+ method: "GET",
+ query: z.optional(
+ z
+ .object({
+ teamId: z.string().optional(),
+ })
+ .meta({
+ description:
+ "The team whose members we should return. If this is not provided the members of the current active team get returned.",
+ }),
+ ),
+ metadata: {
+ openapi: {
+ description: "List the members of the given team.",
+ responses: {
+ "200": {
+ description: "Teams retrieved successfully",
+ content: {
+ "application/json": {
+ schema: {
+ type: "array",
+ items: {
+ type: "object",
+ description: "The team member",
+ properties: {
+ id: {
+ type: "string",
+ description: "Unique identifier of the team member",
+ },
+ userId: {
+ type: "string",
+ description: "The user ID of the team member",
+ },
+ teamId: {
+ type: "string",
+ description:
+ "The team ID of the team the team member is in",
+ },
+ createdAt: {
+ type: "string",
+ format: "date-time",
+ description:
+ "Timestamp when the team member was created",
+ },
+ },
+ required: ["id", "userId", "teamId", "createdAt"],
+ },
+ description: "Array of team member objects within the team",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ use: [orgMiddleware, orgSessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+
+ if (!session.session.activeOrganizationId) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
+ });
+ }
+
+ if (!ctx.query?.teamId) {
+ if (!session.session.activeTeamId) {
+ throw new APIError("FORBIDDEN", {
+ message: ORGANIZATION_ERROR_CODES.YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM,
+ });
+ }
+
+ const member = await adapter.findTeamMember({
+ userId: session.user.id,
+ teamId: session.session.activeTeamId,
+ });
+
+ if (!member) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
+ });
+ }
+
+ const members = await adapter.listTeamMembers({
+ teamId: session.session.activeTeamId,
+ });
+
+ return ctx.json(members);
+ }
+
+ const member = await adapter.findTeamMember({
+ userId: session.user.id,
+ teamId: ctx.query.teamId,
+ });
+
+ if (!member) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
+ });
+ }
+
+ const members = await adapter.listTeamMembers({
+ teamId: ctx.query.teamId,
+ });
+
+ return ctx.json(members);
+ },
+ );
+
+export const addTeamMember = (options: O) =>
+ createAuthEndpoint(
+ "/organization/add-team-member",
+ {
+ method: "POST",
+ body: z.object({
+ teamId: z.string().meta({
+ description: "The team the user should be a member of.",
+ }),
+
+ userId: z.coerce.string().meta({
+ description:
+ "The user Id which represents the user to be added as a member.",
+ }),
+ }),
+ metadata: {
+ openapi: {
+ description: "The newly created member",
+ responses: {
+ "200": {
+ description: "Team member created successfully",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ description: "The team member",
+ properties: {
+ id: {
+ type: "string",
+ description: "Unique identifier of the team member",
+ },
+ userId: {
+ type: "string",
+ description: "The user ID of the team member",
+ },
+ teamId: {
+ type: "string",
+ description:
+ "The team ID of the team the team member is in",
+ },
+ createdAt: {
+ type: "string",
+ format: "date-time",
+ description:
+ "Timestamp when the team member was created",
+ },
+ },
+ required: ["id", "userId", "teamId", "createdAt"],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ use: [orgMiddleware, orgSessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+
+ if (!session.session.activeOrganizationId) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
+ });
+ }
+
+ const currentMember = await adapter.findMemberByOrgId({
+ userId: session.user.id,
+ organizationId: session.session.activeOrganizationId,
+ });
+
+ if (!currentMember) {
+ throw new APIError("BAD_REQUEST", {
+ message:
+ ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
+ });
+ }
+
+ const canUpdateMember = hasPermission({
+ role: currentMember.role,
+ options: ctx.context.orgOptions,
+ permissions: {
+ member: ["update"],
+ },
+ });
+
+ if (!canUpdateMember) {
+ throw new APIError("FORBIDDEN", {
+ message:
+ ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER,
+ });
+ }
+
+ const toBeAddedMember = await adapter.findMemberByOrgId({
+ userId: ctx.body.userId,
+ organizationId: session.session.activeOrganizationId,
+ });
+
+ if (!toBeAddedMember) {
+ throw new APIError("BAD_REQUEST", {
+ message:
+ ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
+ });
+ }
+
+ const teamMember = await adapter.findOrCreateTeamMember({
+ teamId: ctx.body.teamId,
+ userId: ctx.body.userId,
+ });
+
+ return ctx.json(teamMember);
+ },
+ );
+
+export const removeTeamMember = (options: O) =>
+ createAuthEndpoint(
+ "/organization/remove-team-member",
+ {
+ method: "POST",
+ body: z.object({
+ teamId: z.string().meta({
+ description: "The team the user should be removed from.",
+ }),
+
+ userId: z.coerce.string().meta({
+ description: "The user which should be removed from the team.",
+ }),
+ }),
+ metadata: {
+ openapi: {
+ description: "Remove a member from a team",
+ responses: {
+ "200": {
+ description: "Team member removed successfully",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ message: {
+ type: "string",
+ description:
+ "Confirmation message indicating successful removal",
+ enum: ["Team member removed successfully."],
+ },
+ },
+ required: ["message"],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ use: [orgMiddleware, orgSessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+ const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions);
+
+ if (!session.session.activeOrganizationId) {
+ throw new APIError("BAD_REQUEST", {
+ message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION,
+ });
+ }
+
+ const currentMember = await adapter.findMemberByOrgId({
+ userId: session.user.id,
+ organizationId: session.session.activeOrganizationId,
+ });
+
+ if (!currentMember) {
+ throw new APIError("BAD_REQUEST", {
+ message:
+ ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
+ });
+ }
+
+ const canDeleteMember = hasPermission({
+ role: currentMember.role,
+ options: ctx.context.orgOptions,
+ permissions: {
+ member: ["delete"],
+ },
+ });
+
+ if (!canDeleteMember) {
+ throw new APIError("FORBIDDEN", {
+ message:
+ ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER,
+ });
+ }
+
+ const toBeAddedMember = await adapter.findMemberByOrgId({
+ userId: ctx.body.userId,
+ organizationId: session.session.activeOrganizationId,
+ });
+
+ if (!toBeAddedMember) {
+ throw new APIError("BAD_REQUEST", {
+ message:
+ ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION,
+ });
+ }
+
+ await adapter.removeTeamMember({
+ teamId: ctx.body.teamId,
+ userId: ctx.body.userId,
+ });
+
+ return ctx.json({ message: "Team member removed successfully." });
+ },
+ );
diff --git a/packages/better-auth/src/plugins/organization/schema.ts b/packages/better-auth/src/plugins/organization/schema.ts
index fa598d26fa..992a6f674c 100644
--- a/packages/better-auth/src/plugins/organization/schema.ts
+++ b/packages/better-auth/src/plugins/organization/schema.ts
@@ -27,7 +27,6 @@ export const memberSchema = z.object({
userId: z.coerce.string(),
role,
createdAt: z.date().default(() => new Date()),
- teamId: z.string().optional(),
});
export const invitationSchema = z.object({
@@ -49,12 +48,21 @@ export const teamSchema = z.object({
updatedAt: z.date().optional(),
});
+export const teamMemberSchema = z.object({
+ id: z.string().default(generateId),
+ teamId: z.string(),
+ userId: z.string(),
+ createdAt: z.date().default(() => new Date()),
+});
+
export type Organization = z.infer;
export type Member = z.infer;
+export type TeamMember = z.infer;
export type Team = z.infer;
export type Invitation = z.infer;
export type InvitationInput = z.input;
export type MemberInput = z.input;
+export type TeamMemberInput = z.input;
export type OrganizationInput = z.input;
export type TeamInput = z.infer;
diff --git a/packages/better-auth/src/plugins/organization/team.test.ts b/packages/better-auth/src/plugins/organization/team.test.ts
index 8a5c04b21f..fd7eea4be6 100644
--- a/packages/better-auth/src/plugins/organization/team.test.ts
+++ b/packages/better-auth/src/plugins/organization/team.test.ts
@@ -5,23 +5,22 @@ import { createAuthClient } from "../../client";
import { organizationClient } from "./client";
describe("team", async (it) => {
- const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
- await getTestInstance({
- user: {
- modelName: "users",
- },
- plugins: [
- organization({
- async sendInvitationEmail(data, request) {},
- teams: {
- enabled: true,
- },
- }),
- ],
- logger: {
- level: "error",
- },
- });
+ const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({
+ user: {
+ modelName: "users",
+ },
+ plugins: [
+ organization({
+ async sendInvitationEmail() {},
+ teams: {
+ enabled: true,
+ },
+ }),
+ ],
+ logger: {
+ level: "error",
+ },
+ });
const { headers } = await signInWithTestUser();
const client = createAuthClient({
@@ -50,6 +49,11 @@ describe("team", async (it) => {
name: "Invited User",
};
+ const signUpHeaders = new Headers();
+ const signUpRes = await client.signUp.email(invitedUser, {
+ onSuccess: cookieSetter(signUpHeaders),
+ });
+
it("should create an organization and a team", async () => {
const createOrganizationResponse = await client.organization.create({
name: "Test Organization",
@@ -119,29 +123,58 @@ describe("team", async (it) => {
teamId,
});
- const newHeaders = new Headers();
- const signUpRes = await client.signUp.email(invitedUser, {
- onSuccess: cookieSetter(newHeaders),
- });
-
- expect(signUpRes.data?.user).toBeDefined();
-
const invitation = await client.organization.acceptInvitation(
{
invitationId: res.data?.id as string,
},
{
- headers: newHeaders,
+ headers: signUpHeaders,
},
);
expect(invitation.data?.member).toMatchObject({
role: "member",
- teamId,
userId: signUpRes.data?.user.id,
});
});
+ it("should add team to the member's list of teams", async () => {
+ const listUserTeamsRes = await client.organization.listUserTeams(
+ {},
+ {
+ headers: signUpHeaders,
+ },
+ );
+
+ expect(listUserTeamsRes.error).toBeNull();
+ expect(listUserTeamsRes.data).not.toBeNull();
+ expect(listUserTeamsRes.data).toHaveLength(1);
+ });
+
+ it("should be able to list team members in the current active team", async () => {
+ const activeTeamHeaders = new Headers();
+ await client.organization.setActiveTeam(
+ {
+ teamId,
+ },
+ {
+ headers: signUpHeaders,
+ onSuccess: cookieSetter(activeTeamHeaders),
+ },
+ );
+
+ const res = await client.organization.listTeamMembers(
+ {},
+ {
+ headers: activeTeamHeaders,
+ },
+ );
+
+ expect(res.error).toBeNull();
+ expect(res.data).not.toBeNull();
+ expect(res.data).toHaveLength(1);
+ });
+
it("should get full organization", async () => {
const organization = await client.organization.getFullOrganization({
fetchOptions: {
@@ -313,3 +346,275 @@ describe("team", async (it) => {
expect(res2.error?.code).toEqual("TEAM_MEMBER_LIMIT_REACHED");
});
});
+
+describe("mulit team support", async (it) => {
+ const { auth, signInWithTestUser, cookieSetter } = await getTestInstance(
+ {
+ plugins: [
+ organization({
+ async sendInvitationEmail() {},
+ teams: {
+ enabled: true,
+ defaultTeam: {
+ enabled: true,
+ },
+ },
+ }),
+ ],
+ logger: {
+ level: "error",
+ },
+ },
+ {
+ testWith: "sqlite",
+ },
+ );
+
+ const admin = await signInWithTestUser();
+
+ const invitedUser = await auth.api.signUpEmail({
+ body: {
+ name: "Invited User",
+ email: "invited@email.com",
+ password: "password",
+ },
+ returnHeaders: true,
+ });
+
+ let organizationId: string | null = null;
+
+ let team1Id: string | null = null;
+ let team2Id: string | null = null;
+ let team3Id: string | null = null;
+
+ let invitationId: string | null = null;
+
+ it("should create an organization to test multi team support", async () => {
+ const organization = await auth.api.createOrganization({
+ headers: admin.headers,
+ body: {
+ name: "Test Organization",
+ slug: "test-org",
+ metadata: {
+ test: "organization-metadata",
+ },
+ },
+ });
+
+ expect(organization?.id).toBeDefined();
+ expect(organization?.name).toBe("Test Organization");
+
+ organizationId = organization?.id as string;
+ });
+
+ it("should create 3 teams", async () => {
+ expect(organizationId).toBeDefined();
+ if (!organizationId) throw Error("can not run test");
+
+ const team1 = await auth.api.createTeam({
+ headers: admin.headers,
+ body: {
+ name: "Team One",
+ organizationId,
+ },
+ });
+
+ expect(team1.id).toBeDefined();
+ expect(team1.organizationId).toBe(organizationId);
+
+ team1Id = team1.id;
+
+ const team2 = await auth.api.createTeam({
+ headers: admin.headers,
+ body: {
+ name: "Team Two",
+ organizationId,
+ },
+ });
+
+ expect(team2.id).toBeDefined();
+ expect(team2.organizationId).toBe(organizationId);
+
+ team2Id = team2.id;
+
+ const team3 = await auth.api.createTeam({
+ headers: admin.headers,
+ body: {
+ name: "Team Three",
+ organizationId,
+ },
+ });
+
+ expect(team3.id).toBeDefined();
+ expect(team3.organizationId).toBe(organizationId);
+
+ team3Id = team3.id;
+ });
+
+ it("should invite user to all 3 teams", async () => {
+ expect(organizationId).toBeDefined();
+ expect(team1Id).toBeDefined();
+ expect(team2Id).toBeDefined();
+ expect(team3Id).toBeDefined();
+
+ if (!organizationId || !team1Id || !team2Id || !team3Id)
+ throw Error("can not run test");
+
+ const invitation = await auth.api.createInvitation({
+ headers: admin.headers,
+ body: {
+ email: invitedUser.response.user.email,
+ role: "member",
+ organizationId,
+ teamId: [team1Id, team2Id, team3Id],
+ },
+ });
+
+ expect(invitation.id).toBeDefined();
+ expect(invitation.teamId).toBe([team1Id, team2Id, team3Id].join(","));
+
+ invitationId = invitation.id;
+ });
+
+ it("should accept invite and join all 3 teams", async () => {
+ expect(invitationId).toBeDefined();
+
+ if (!invitationId) throw Error("can not run test");
+
+ const accept = await auth.api.acceptInvitation({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ body: {
+ invitationId,
+ },
+ });
+
+ expect(accept?.member).toBeDefined();
+ expect(accept?.invitation).toBeDefined();
+ });
+
+ it("should have jonied all 3 teams", async () => {
+ expect(invitationId).toBeDefined();
+
+ if (!invitationId) throw Error("can not run test");
+
+ const teams = await auth.api.listUserTeams({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ });
+
+ expect(teams).toHaveLength(3);
+ });
+
+ let activeTeamCookie: string | null = null;
+
+ it("should allow you to set one of the teams as active", async () => {
+ expect(team1Id).toBeDefined();
+ expect(organizationId).toBeDefined();
+
+ if (!team1Id || !organizationId) throw Error("can not run test");
+
+ const team = await auth.api.setActiveTeam({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ body: {
+ teamId: team1Id,
+ },
+ returnHeaders: true,
+ });
+
+ expect(team.response?.id).toBe(team1Id);
+ expect(team.response?.organizationId).toBe(organizationId);
+
+ activeTeamCookie = team.headers.getSetCookie()[0];
+ });
+
+ it("should allow you to list team members of the current active team", async () => {
+ expect(activeTeamCookie).toBeDefined();
+
+ if (!activeTeamCookie) throw Error("can not run test");
+
+ const members = await auth.api.listTeamMembers({
+ headers: { cookie: activeTeamCookie },
+ });
+
+ expect(members).toHaveLength(1);
+ expect(members.at(0)?.teamId).toBe(team1Id);
+ });
+
+ it("should allow user to list team members of any team the user is in", async () => {
+ expect(team2Id).toBeDefined();
+ expect(team3Id).toBeDefined();
+
+ if (!team2Id || !team3Id) throw Error("can not run test");
+
+ const team2Members = await auth.api.listTeamMembers({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ query: {
+ teamId: team2Id,
+ },
+ });
+
+ expect(team2Members).toHaveLength(1);
+ expect(team2Members.at(0)?.teamId).toBe(team2Id);
+
+ const team3Members = await auth.api.listTeamMembers({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ query: {
+ teamId: team3Id,
+ },
+ });
+
+ expect(team3Members).toHaveLength(1);
+ expect(team3Members.at(0)?.teamId).toBe(team3Id);
+ });
+
+ let team4Id: string | null = null;
+ it("should directly add a member to a team", async () => {
+ expect(organizationId).toBeDefined();
+ if (!organizationId) throw Error("can not run test");
+
+ const team = await auth.api.createTeam({
+ headers: admin.headers,
+ body: {
+ name: "Team Four",
+ organizationId,
+ },
+ });
+
+ const teamMember = await auth.api.addTeamMember({
+ headers: admin.headers,
+ body: {
+ userId: invitedUser.response.user.id,
+ teamId: team.id,
+ },
+ });
+
+ expect(teamMember.teamId).toBe(team.id);
+ expect(teamMember.userId).toBe(invitedUser.response.user.id);
+
+ const teams = await auth.api.listUserTeams({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ });
+
+ expect(teams).toHaveLength(4);
+
+ team4Id = team.id;
+ });
+
+ it("should remove a member from a team", async () => {
+ expect(team4Id).toBeDefined();
+ if (!team4Id) throw Error("can not run test");
+
+ await auth.api.removeTeamMember({
+ headers: admin.headers,
+ body: {
+ userId: invitedUser.response.user.id,
+ teamId: team4Id,
+ },
+ });
+
+ const teams = await auth.api.listUserTeams({
+ headers: { cookie: invitedUser.headers.getSetCookie()[0] },
+ });
+
+ expect(teams).toHaveLength(3);
+ });
+});
diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts
index 695ff3f649..6fbfd79b33 100644
--- a/packages/better-auth/src/plugins/organization/types.ts
+++ b/packages/better-auth/src/plugins/organization/types.ts
@@ -1,7 +1,13 @@
import type { FieldAttribute } from "../../db";
import type { User, Session, AuthContext } from "../../types";
import type { AccessControl, Role } from "../access";
-import type { Invitation, Member, Organization, Team } from "./schema";
+import type {
+ Invitation,
+ Member,
+ Organization,
+ Team,
+ TeamMember,
+} from "./schema";
export interface OrganizationOptions {
/**
@@ -217,6 +223,7 @@ export interface OrganizationOptions {
session?: {
fields?: {
activeOrganizationId?: string;
+ activeTeamId?: string;
};
};
organization?: {
@@ -256,6 +263,13 @@ export interface OrganizationOptions {
[key in string]: FieldAttribute;
};
};
+
+ teamMember?: {
+ modelName?: string;
+ fields?: {
+ [key in keyof Omit]?: string;
+ };
+ };
};
/**
* Configure how organization deletion is handled
diff --git a/packages/better-auth/src/social-providers/social.test.ts b/packages/better-auth/src/social-providers/social.test.ts
index 30bff27019..0dc029dcc5 100644
--- a/packages/better-auth/src/social-providers/social.test.ts
+++ b/packages/better-auth/src/social-providers/social.test.ts
@@ -11,6 +11,7 @@ import Database from "better-sqlite3";
import { getMigrations } from "../db";
let server = new OAuth2Server();
+let port = 8005;
vi.mock("../oauth2", async (importOriginal) => {
const original = (await importOriginal()) as any;
@@ -75,7 +76,7 @@ vi.mock("../oauth2", async (importOriginal) => {
expect(refreshToken).toBeDefined();
expect(options.clientId).toBe("test-client-id");
expect(options.clientSecret).toBe("test-client-secret");
- expect(tokenEndpoint).toBe("http://localhost:8080/token");
+ expect(tokenEndpoint).toBe(`http://localhost:${port}/token`);
const data: GoogleProfile = {
email: "user@email.com",
@@ -150,8 +151,8 @@ describe("Social Providers", async (c) => {
beforeAll(async () => {
await server.issuer.keys.generate("RS256");
server.issuer.on;
- await server.start(8080, "localhost");
- console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
+ await server.start(port, "localhost");
+ console.log("Issuer URL:", server.issuer.url); // -> http://localhost:${port}
});
afterAll(async () => {
await server.stop().catch(console.error);
@@ -206,7 +207,7 @@ describe("Social Providers", async (c) => {
clientKey: "test-client-key",
clientSecret: "test-client-secret",
},
- tokenEndpoint: "http://localhost:8080/token",
+ tokenEndpoint: `http://localhost:${port}/token`,
});
return tokens;
}
@@ -382,7 +383,7 @@ describe("Social Providers", async (c) => {
if (!authUrl) throw new Error("No auth url found");
const mockEndpoint = authUrl.replace(
"https://accounts.google.com/o/oauth2/auth",
- "http://localhost:8080/authorize",
+ `http://localhost:${port}/authorize`,
);
const result = await simulateOAuthFlowRefresh(mockEndpoint, headers);
const { accessToken, refreshToken } = result;
diff --git a/packages/better-auth/src/types/types.test.ts b/packages/better-auth/src/types/types.test.ts
index f1c360016b..806e8d7b42 100644
--- a/packages/better-auth/src/types/types.test.ts
+++ b/packages/better-auth/src/types/types.test.ts
@@ -52,7 +52,6 @@ describe("general types", async (it) => {
token: string;
ipAddress?: string | undefined | null;
userAgent?: string | undefined | null;
- activeOrganizationId?: string | undefined | null;
}>();
});
});