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:
| Model | Holds |
|---|---|
Organization | name, unique slug, timestamps |
Membership | organization × user × role, unique per pair |
Invitation | email, 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:
| Plan | Seats |
|---|---|
| Free | 1 |
| Pro | 5 |
| 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
noindexand 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.
getActiveOrganizationfalls through whenuser.activeOrganizationIdpoints 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.