Organizations

Workspaces, roles, invitations and seat limits that are enforced rather than displayed.

Every user belongs to at least one workspace. On first sign-in one is created for them, so no screen ever has to render an empty state for “belongs to nothing”.

# Model

Three tables, in prisma/schema.prisma:

ModelHolds
Organizationname, unique slug, timestamps
Membershiporganization × user × role, unique per pair
Invitationemail, role, token, expiry, who invited

All the logic lives in src/lib/organizations.ts. Pages and actions call it; they never query these tables directly.

# Roles

Ordered, and the order is the authorisation model:

OWNER  >  ADMIN  >  MEMBER
  • -- canManage(actor, target) is strict — a role never manages its own rank. An ADMIN cannot remove another ADMIN.
  • -- hasAtLeastRole(actor, required) is inclusive, for read checks.
  • -- requireOrgRole(orgId, userId, role) throws unless the caller really sits in that organization at that level.

Call requireOrgRole in every mutation

An organization id that arrives in a FormData is a claim, not a fact. The server actions in dashboard/organization/actions.ts re-resolve the active workspace and re-check the role against the database on every call — copy that shape.

# Seats

Seat limits come from PLANS[...].limits.seats and are enforced, not decorative:

PlanSeats
Free1
Pro5
Business-1 — unlimited

getSeatUsage(orgId) counts members plus invitations still open. That is the important part: without it a Free workspace could invite twenty people and only meet the limit when they all accepted.

const seats = await getSeatUsage(org.id);
// { members, pending, used, limit, plan, unlimited, remaining }

if (!seats.unlimited && seats.remaining < 1) {
  throw new OrganizationError("SEAT_LIMIT");
}

The check runs twice — when an invitation is created, and again when it is accepted — because the owner may have downgraded in between.

# Invitations

An invitation is a link tied to one email address, valid seven days.

  • -- The accept page is noindex and acceptance is a POST. On GET, a link previewer or spam filter in the recipient's inbox would burn the invitation before they ever clicked.
  • -- The signed-in email must match the invited address. An invite link is a bearer token; without that check, anyone it is forwarded to joins the workspace.
  • -- Re-inviting the same address refreshes the token rather than erroring — the usual reason to invite twice is that the first mail was lost.

Resend failures are reported honestly

If the mail cannot be sent, the action returns INVITE_SENT_FAILED: the seat is reserved, and the UI offers the link to copy instead of claiming the invitation was sent. Never report success for a side effect that failed.

# Billing

Billing deliberately stays on the User. A workspace's plan is its owner's plan, resolved by getOrganizationPlan(orgId).

  • -- One Stripe subscription covers the whole team.
  • -- The checkout and webhook flow you already have is untouched.
  • -- Seats are what you sell: a bigger team needs a bigger plan.

Moving Stripe onto Organization is a rewrite, not a refactor

If you need per-organization subscriptions, the webhook has to resolve an organization from the Stripe customer instead of a user, and every plan check changes with it. Decide that before you build on top of the current model.

# Recipes

Scope a query to the active workspace

const user = await requireUser();
const org  = await getActiveOrganization(user);

const rows = await prisma.project.findMany({
  where: { organizationId: org.id },
});

Guard a mutation

"use server";
export async function renameProject(formData: FormData) {
  const user = await requireUser();
  const org  = await getActiveOrganization(user);
  await requireOrgRole(org.id, user.id, "ADMIN");   // <- not optional
  // ...
}

Show something only to admins

const isManager = hasAtLeastRole(org.role, "ADMIN");
{isManager && <InviteForm ... />}

Hiding the form is a courtesy. The server check is the security.

# Pitfalls

  • -- The last owner. assertNotLastOwner() refuses to remove or demote the only OWNER — an organization without one can never be billed or managed again. Do not route around it.
  • -- A stale active workspace. getActiveOrganization falls through when user.activeOrganizationId points at a deleted org or one the user was removed from. Keep that fallback.
  • -- Switching workspace changes the shell. Revalidate with revalidatePath("/dashboard", "layout"), not just the page.
  • -- Roles are re-read, never trusted from the session. A user demoted a second ago must not still act as an admin.