From 28c846a7cc210afbc7603caabf700d6f2cf9874f Mon Sep 17 00:00:00 2001 From: Bereket Engida Date: Sat, 19 Jul 2025 12:07:35 -0700 Subject: [PATCH] feat(organization): multi-team support --- docs/content/docs/plugins/organization.mdx | 146 +++++- .../src/plugins/organization/adapter.ts | 141 +++++- .../src/plugins/organization/call.ts | 4 +- .../src/plugins/organization/error-codes.ts | 8 + .../plugins/organization/organization.test.ts | 4 +- .../src/plugins/organization/organization.ts | 130 ++++- .../organization/routes/crud-invites.ts | 173 ++++--- .../organization/routes/crud-members.ts | 8 +- .../plugins/organization/routes/crud-org.ts | 23 +- .../plugins/organization/routes/crud-team.ts | 479 +++++++++++++++++- .../src/plugins/organization/schema.ts | 10 +- .../src/plugins/organization/team.test.ts | 357 ++++++++++++- .../src/plugins/organization/types.ts | 16 +- .../src/social-providers/social.test.ts | 11 +- packages/better-auth/src/types/types.test.ts | 1 - 15 files changed, 1383 insertions(+), 128 deletions(-) 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; }>(); }); });