UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

706 lines (705 loc) • 19.7 kB
import { getDate } from "../../utils/date.mjs"; import { parseJSON } from "../../client/parser.mjs"; import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context"; import { BetterAuthError } from "@better-auth/core/error"; import { filterOutputFields } from "@better-auth/core/utils/db"; //#region src/plugins/organization/adapter.ts /** * Resolves the configured per-team member cap to a concrete number for a given * team-add. Returns `undefined` only when no cap is configured. Throws when the * cap is a function but no session is available to evaluate it, so a sessionless * server-side add fails closed instead of silently bypassing the limit. */ async function resolveMaximumMembersPerTeam(teams, context) { const maximumMembersPerTeam = teams?.maximumMembersPerTeam; if (maximumMembersPerTeam === void 0) return void 0; if (typeof maximumMembersPerTeam === "number") return maximumMembersPerTeam; if (!context.session) throw new BetterAuthError("`teams.maximumMembersPerTeam` is configured as a function but no session is available to evaluate it. Provide a session-bearing request or configure a numeric limit."); return await maximumMembersPerTeam({ teamId: context.teamId, session: context.session, organizationId: context.organizationId }); } const getOrgAdapter = (context, options) => { const baseAdapter = context.adapter; const orgAdditionalFields = options?.schema?.organization?.additionalFields; const memberAdditionalFields = options?.schema?.member?.additionalFields; const invitationAdditionalFields = options?.schema?.invitation?.additionalFields; const teamAdditionalFields = options?.schema?.team?.additionalFields; return { findOrganizationBySlug: async (slug) => { return filterOutputFields(await (await getCurrentAdapter(baseAdapter)).findOne({ model: "organization", where: [{ field: "slug", value: slug }] }), orgAdditionalFields); }, createOrganization: async (data) => { const organization = await (await getCurrentAdapter(baseAdapter)).create({ model: "organization", data: { ...data.organization, metadata: data.organization.metadata ? JSON.stringify(data.organization.metadata) : void 0 }, forceAllowId: true }); return filterOutputFields({ ...organization, metadata: organization.metadata && typeof organization.metadata === "string" ? JSON.parse(organization.metadata) : void 0 }, orgAdditionalFields); }, findMemberByEmail: async (data) => { const adapter = await getCurrentAdapter(baseAdapter); const user = await adapter.findOne({ model: "user", where: [{ field: "email", value: data.email.toLowerCase() }] }); if (!user) return null; const member = await adapter.findOne({ model: "member", where: [{ field: "organizationId", value: data.organizationId }, { field: "userId", value: user.id }] }); if (!member) return null; return { ...member, user: { id: user.id, name: user.name, email: user.email, image: user.image } }; }, listMembers: async (data) => { const adapter = await getCurrentAdapter(baseAdapter); const members = await Promise.all([adapter.findMany({ model: "member", where: [{ field: "organizationId", value: data.organizationId }, ...data.filter?.field ? [{ field: data.filter?.field, value: data.filter?.value, ...data.filter.operator ? { operator: data.filter.operator } : {} }] : []], limit: data.limit || (typeof options?.membershipLimit === "number" ? options.membershipLimit : 100) || 100, offset: data.offset || 0, sortBy: data.sortBy ? { field: data.sortBy, direction: data.sortOrder || "asc" } : void 0 }), adapter.count({ model: "member", where: [{ field: "organizationId", value: data.organizationId }, ...data.filter?.field ? [{ field: data.filter?.field, value: data.filter?.value, ...data.filter.operator ? { operator: data.filter.operator } : {} }] : []] })]); const users = await adapter.findMany({ model: "user", where: [{ field: "id", value: members[0].map((member) => member.userId), operator: "in" }], limit: members[0].length }); return { members: members[0].map((member) => { const user = users.find((user) => user.id === member.userId); if (!user) throw new BetterAuthError("Unexpected error: User not found for member"); return { ...member, user: { id: user.id, name: user.name, email: user.email, image: user.image } }; }), total: members[1] }; }, findMemberByOrgId: async (data) => { const result = await (await getCurrentAdapter(baseAdapter)).findOne({ model: "member", where: [{ field: "userId", value: data.userId }, { field: "organizationId", value: data.organizationId }], join: { user: true } }); if (!result || !result.user) return null; const { user, ...member } = result; return { ...member, user: { id: user.id, name: user.name, email: user.email, image: user.image } }; }, findMemberById: async (memberId) => { const result = await (await getCurrentAdapter(baseAdapter)).findOne({ model: "member", where: [{ field: "id", value: memberId }], join: { user: true } }); if (!result) return null; const { user, ...member } = result; return { ...member, user: { id: user.id, name: user.name, email: user.email, image: user.image } }; }, createMember: async (data) => { return await (await getCurrentAdapter(baseAdapter)).create({ model: "member", data: { ...data, createdAt: /* @__PURE__ */ new Date() } }); }, updateMember: async (memberId, role) => { return await (await getCurrentAdapter(baseAdapter)).update({ model: "member", where: [{ field: "id", value: memberId }], update: { role } }); }, deleteMember: async ({ memberId, organizationId, userId: _userId }) => { return runWithTransaction(baseAdapter, async () => { const adapter = await getCurrentAdapter(baseAdapter); let userId; if (!_userId) { const member = await adapter.findOne({ model: "member", where: [{ field: "id", value: memberId }] }); if (!member) throw new BetterAuthError("Member not found"); userId = member.userId; } else userId = _userId; const member = await adapter.delete({ model: "member", where: [{ field: "id", value: memberId }] }); if (options?.teams?.enabled) { const teams = await adapter.findMany({ model: "team", where: [{ field: "organizationId", value: organizationId }] }); if (teams.length > 0) await adapter.deleteMany({ model: "teamMember", where: [{ field: "userId", value: userId }, { field: "teamId", value: teams.map((team) => team.id), operator: "in" }] }); } return member; }); }, updateOrganization: async (organizationId, data) => { const organization = await (await getCurrentAdapter(baseAdapter)).update({ model: "organization", where: [{ field: "id", value: organizationId }], update: { ...data, metadata: typeof data.metadata === "object" ? JSON.stringify(data.metadata) : data.metadata } }); if (!organization) return null; return filterOutputFields({ ...organization, metadata: organization.metadata ? parseJSON(organization.metadata) : void 0 }, orgAdditionalFields); }, deleteOrganization: async (organizationId) => { return runWithTransaction(baseAdapter, async () => { const adapter = await getCurrentAdapter(baseAdapter); await adapter.deleteMany({ model: "member", where: [{ field: "organizationId", value: organizationId }] }); await adapter.deleteMany({ model: "invitation", where: [{ field: "organizationId", value: organizationId }] }); await adapter.delete({ model: "organization", where: [{ field: "id", value: organizationId }] }); return organizationId; }); }, setActiveOrganization: async (sessionToken, organizationId, ctx) => { return await context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId }); }, findOrganizationById: async (organizationId) => { return filterOutputFields(await (await getCurrentAdapter(baseAdapter)).findOne({ model: "organization", where: [{ field: "id", value: organizationId }] }), orgAdditionalFields); }, checkMembership: async ({ userId, organizationId }) => { return await (await getCurrentAdapter(baseAdapter)).findOne({ model: "member", where: [{ field: "userId", value: userId }, { field: "organizationId", value: organizationId }] }); }, /** * @requires db */ findFullOrganization: async ({ organizationId, isSlug, includeTeams, membersLimit }) => { const adapter = await getCurrentAdapter(baseAdapter); const result = await adapter.findOne({ model: "organization", where: [{ field: isSlug ? "slug" : "id", value: organizationId }], join: { invitation: true, member: membersLimit ? { limit: membersLimit } : true, ...includeTeams ? { team: true } : {} } }); if (!result) return null; const { invitation: invitations, member: members, team: teams, ...org } = result; const userIds = members.map((member) => member.userId); const users = userIds.length > 0 ? await adapter.findMany({ model: "user", where: [{ field: "id", value: userIds, operator: "in" }], limit: (typeof options?.membershipLimit === "number" ? options.membershipLimit : 100) || 100 }) : []; const userMap = new Map(users.map((user) => [user.id, user])); const membersWithUsers = members.map((member) => { const user = userMap.get(member.userId); if (!user) throw new BetterAuthError("Unexpected error: User not found for member"); return { ...filterOutputFields(member, memberAdditionalFields), user: { id: user.id, name: user.name, email: user.email, image: user.image } }; }); const filteredOrg = filterOutputFields(org, orgAdditionalFields); const filteredInvitations = invitations.map((inv) => filterOutputFields(inv, invitationAdditionalFields)); const filteredTeams = teams?.map((team) => filterOutputFields(team, teamAdditionalFields)); return { ...filteredOrg, invitations: filteredInvitations, members: membersWithUsers, teams: filteredTeams }; }, listOrganizations: async (userId) => { const result = await (await getCurrentAdapter(baseAdapter)).findMany({ model: "member", where: [{ field: "userId", value: userId }], join: { organization: true } }); if (!result || result.length === 0) return []; return result.map((member) => filterOutputFields(member.organization, orgAdditionalFields)); }, createTeam: async (data) => { return await (await getCurrentAdapter(baseAdapter)).create({ model: "team", data, forceAllowId: true }); }, findTeamById: async ({ teamId, organizationId, includeTeamMembers }) => { const result = await (await getCurrentAdapter(baseAdapter)).findOne({ model: "team", where: [{ field: "id", value: teamId }, ...organizationId ? [{ field: "organizationId", value: organizationId }] : []], join: { ...includeTeamMembers ? { teamMember: true } : {} } }); if (!result) return null; const { teamMember, ...team } = result; return { ...team, ...includeTeamMembers ? { members: teamMember } : {} }; }, updateTeam: async (teamId, data) => { const adapter = await getCurrentAdapter(baseAdapter); if ("id" in data) data.id = void 0; return await adapter.update({ model: "team", where: [{ field: "id", value: teamId }], update: { ...data } }); }, deleteTeam: async (teamId) => { const adapter = await getCurrentAdapter(baseAdapter); await adapter.deleteMany({ model: "teamMember", where: [{ field: "teamId", value: teamId }] }); return await adapter.delete({ model: "team", where: [{ field: "id", value: teamId }] }); }, listTeams: async (organizationId) => { return await (await getCurrentAdapter(baseAdapter)).findMany({ model: "team", where: [{ field: "organizationId", value: organizationId }] }); }, createTeamInvitation: async ({ email, role, teamId, organizationId, inviterId, expiresIn = 1e3 * 60 * 60 * 48 }) => { const adapter = await getCurrentAdapter(baseAdapter); const expiresAt = getDate(expiresIn); return await adapter.create({ model: "invitation", data: { email, role, organizationId, teamId, inviterId, status: "pending", expiresAt } }); }, setActiveTeam: async (sessionToken, teamId, ctx) => { return await context.internalAdapter.updateSession(sessionToken, { activeTeamId: teamId }); }, listTeamMembers: async (data) => { return await (await getCurrentAdapter(baseAdapter)).findMany({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }] }); }, countTeamMembers: async (data) => { return await (await getCurrentAdapter(baseAdapter)).count({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }] }); }, countMembers: async (data) => { return await (await getCurrentAdapter(baseAdapter)).count({ model: "member", where: [{ field: "organizationId", value: data.organizationId }] }); }, listTeamsByUser: async (data) => { return (await (await getCurrentAdapter(baseAdapter)).findMany({ model: "teamMember", where: [{ field: "userId", value: data.userId }], join: { team: true } })).map((result) => result.team); }, findTeamMember: async (data) => { return await (await getCurrentAdapter(baseAdapter)).findOne({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }, { field: "userId", value: data.userId }] }); }, findOrCreateTeamMember: async (data) => { const adapter = await getCurrentAdapter(baseAdapter); 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({ model: "teamMember", data: { teamId: data.teamId, userId: data.userId, createdAt: /* @__PURE__ */ new Date() } }); }, /** * Adds a user to a team only when the team is below its member limit, * reading the count and creating the membership in one transaction. * Returns the existing membership unchanged (no capacity charge) when the * user already belongs to the team. * * FIXME(team-cap-race): the count-then-create is not atomic under READ * COMMITTED, so two concurrent adds can both pass the count check and * exceed maximumMembersPerTeam. A durable fix needs a unique constraint on * teamMember(teamId, userId) or serializable isolation. Affects every * caller (acceptInvitation, addMember, addTeamMember). */ addTeamMemberWithLimit: async (data) => { return runWithTransaction(baseAdapter, async () => { const adapter = await getCurrentAdapter(baseAdapter); const existing = await adapter.findOne({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }, { field: "userId", value: data.userId }] }); if (existing) return { status: "added", member: existing }; if (await adapter.count({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }] }) >= data.maximumMembersPerTeam) return { status: "limitReached" }; return { status: "added", member: await adapter.create({ model: "teamMember", data: { teamId: data.teamId, userId: data.userId, createdAt: /* @__PURE__ */ new Date() } }) }; }); }, removeTeamMember: async (data) => { await (await getCurrentAdapter(baseAdapter)).deleteMany({ model: "teamMember", where: [{ field: "teamId", value: data.teamId }, { field: "userId", value: data.userId }] }); }, findInvitationsByTeamId: async (teamId) => { return await (await getCurrentAdapter(baseAdapter)).findMany({ model: "invitation", where: [{ field: "teamId", value: teamId }] }); }, listUserInvitations: async (email) => { return (await (await getCurrentAdapter(baseAdapter)).findMany({ model: "invitation", where: [{ field: "email", value: email.toLowerCase() }], join: { organization: true } })).filter(Boolean).map(({ organization, ...inv }) => ({ ...inv, organizationName: organization?.name })); }, createInvitation: async ({ invitation, user }) => { const adapter = await getCurrentAdapter(baseAdapter); const expiresAt = getDate(options?.invitationExpiresIn || 3600 * 48, "sec"); return await adapter.create({ model: "invitation", data: { status: "pending", expiresAt, createdAt: /* @__PURE__ */ new Date(), inviterId: user.id, ...invitation, teamId: invitation.teamIds.length > 0 ? invitation.teamIds.join(",") : null }, forceAllowId: true }); }, findInvitationById: async (id) => { return await (await getCurrentAdapter(baseAdapter)).findOne({ model: "invitation", where: [{ field: "id", value: id }] }); }, findPendingInvitation: async (data) => { return (await (await getCurrentAdapter(baseAdapter)).findMany({ model: "invitation", where: [ { field: "email", value: data.email.toLowerCase() }, { field: "organizationId", value: data.organizationId }, { field: "status", value: "pending" } ] })).filter((invite) => new Date(invite.expiresAt) > /* @__PURE__ */ new Date()); }, findPendingInvitations: async (data) => { return (await (await getCurrentAdapter(baseAdapter)).findMany({ model: "invitation", where: [{ field: "organizationId", value: data.organizationId }, { field: "status", value: "pending" }] })).filter((invite) => new Date(invite.expiresAt) > /* @__PURE__ */ new Date()); }, listInvitations: async (data) => { return await (await getCurrentAdapter(baseAdapter)).findMany({ model: "invitation", where: [{ field: "organizationId", value: data.organizationId }] }); }, updateInvitation: async (data) => { const adapter = await getCurrentAdapter(baseAdapter); const where = [{ field: "id", value: data.invitationId }]; if (data.fromStatus) where.push({ field: "status", value: data.fromStatus }); return await adapter.incrementOne({ model: "invitation", where, increment: {}, set: { status: data.status } }); } }; }; //#endregion export { getOrgAdapter, resolveMaximumMembersPerTeam };