Better Enrollment

Organizations

Org invites, seat limits, and platform controls.

Org features switch on when the organization plugin is detected. Pass the same ac and roles objects you gave the org plugin:

auth.ts
import { organization } from "better-auth/plugins";
import { betterEnrollment, roleGate } from "@octopi-ai/better-enrollment";

plugins: [
  admin(),
  organization({
    ac, roles,
    allowUserToCreateOrganization: roleGate(["admin", "org-creator"]),
  }),
  betterEnrollment({
    organization: {
      ac, roles,
      defaultOrganizationRole: "member",
      defaultSeatLimit: 10,
    },
  }),
],

Redeeming an org invite writes the member row (and team membership when teamId is set) in the same flow that creates the account. Redemption re-validates everything, since state drifts between create and accept.

Active organization is a product decision

After an org-join or org-create invite is redeemed, the plugin does not select an active organization. Whether to switch the user into the new org automatically at sign-in or let them pick through an org switcher is your app's call: set it with the org plugin's setActive where it fits your flow.

Seat limits

Seat limits resolve in order: resolveSeatLimit(org) callback, the org's seatLimit column, defaultSeatLimit, then unlimited. Seats used = members + pending invite reservations (a private invite reserves one seat, a public one reserves maxUses - useCount). Enforcement happens at creation and again inside a guarded write at redemption, so parallel accepts never overshoot.

await authClient.invite.org.setSeatLimit({ organizationId, seatLimit: 25 });
await authClient.invite.org.usage({ organizationId });
// -> { seatLimit, members, pendingReserved, remaining }

Platform controls

App-admin only: disable, enable, and delete organizations. A disabled org refuses invite creation, redemption, and org plugin mutations. Member accounts are never touched by default; banMembers: true additionally bans every member app-wide, for fraud takedowns.

await authClient.invite.org.disable({ organizationId });
await authClient.invite.org.enable({ organizationId });
await authClient.invite.org.delete({ organizationId });

Who can do what

ActionApp adminOrg owner or adminMember with invitation:createPlain member
Create app / org-create inviteYesNoNoNo
Create org-join inviteNoYesYesNo
List invitesAllOwn orgOwn orgNo
Revoke or deleteModeration backstopOwn orgOwn org (invitation:cancel)No
Seat limits, disable, delete orgYesNoNoNo

App admins come from the admin plugin's AC file (ac and roles passed to the plugin) or, without one, from adminRoles (default ["admin"]), adminUserIds, or your own canManageInvites(user) callback; see Permissions. Org permissions are the org plugin's own access control. Cross-org calls return FORBIDDEN, and unknown org ids look identical so they cannot be enumerated.

One actor sits outside this table: trusted server code. The server-only createSystemInvite endpoint can create any invite kind, including org-join, without a session or org membership; seat limits, disabled-org checks, and the allowOwnerInvites switch still apply. System invites store createdByUserId as null and a self-declared inviter, so they remain distinguishable from member-created invites in the audit trail, but their attribution is only as trustworthy as the code that called the endpoint.

Where to go next

Last updated on

On this page