Skip to content

Accounts

Organizations

Data belongs to an organization, and a solo user is an organization of one. Members, roles, seats, invitations and plans all hang off it. You work here when you add routes or pages that act on an organization's data, change the roles, or add a second level such as branches. It is all switched from the orgs block in starter.config.ts(Licensees only).

How it works

  1. Four tables hold it: organizations, org_members, org_invitations and org_audit_log. They ship on every build. orgs.enabled adds or removes routes and pages, never tables.
  2. An organization is addressed by its public_id, 16 lowercase characters such as 3k9x0v7q2m8d1c4f. URLs and API routes use it. The integer id stays on the server.
  3. The organization a request acts on comes from the URL, never from the session. requireOrgScope resolves it and reads the caller's roles on every request, so a role change or a removal applies on the next one.
  4. Each signup gets an organization of its own with the new account as owner, while orgs.createAtSignup is on.
  5. Plans and seats belong to the root organization. Checkout and the billing portal take the organization by public id and need org:billing there (Payments).

Scope a route to an organization

Every route that reads or writes an organization's rows starts with requireOrgScope(event, publicId, permission?) from server/tenancy/scope.ts(Licensees only). server/api/orgs/[org]/projects.get.ts:

import { eq } from 'drizzle-orm'

import { requireOrgScope } from '#server/tenancy/scope'

export default defineEventHandler(async (event) => {
  const scope = await requireOrgScope(event, getRouterParam(event, 'org') ?? '')

  return useDrizzle(event)
    .select()
    .from(tables.projects)
    .where(eq(tables.projects.orgId, scope.orgId))
})

It answers 401 without a verified session, and 404 when the organization does not exist, is closed, or the caller is not a member. Pass a permission as the third argument and a member without it gets 403 with data.reason: 'org-permission-required':

const scope = await requireOrgScope(
  event,
  getRouterParam(event, 'org') ?? '',
  'org:manage',
)

The returned OrgScope carries orgId, rootOrgId, publicId, level, roles (every role the caller holds there) and actorUserId. Take ids for writes from it, never from the request body.

For a public page that shows an organization to visitors without a session, resolvePublicOrg(event, publicId) returns { orgId, publicId } or null. Its type can't be passed where an OrgScope is expected, so a public read can't reach a write.

To gate on a plan, pass the scope's root:

await requirePlanGate(event, scope)
await requirePermission(event, 'insights:read', scope.rootOrgId)

With organizations on, a plan check that names no organization answers 500. Payments covers both guards.

Add pages under an organization

Pages in app/pages/o/[org]/(Licensees only) read the organization from route.params.org:

<script setup lang="ts">
definePageMeta({ layout: 'dashboard', middleware: 'auth' })

const route = useRoute()
const org = computed(() => String(route.params.org ?? ''))
const { can, member } = useOrgRole(org)
</script>

A page like this keeps the app's sidebar and the full width, as the overview at /o/<id> does. orgManagement: true in definePageMeta makes it one of the organization's settings pages instead, next to General, Members, Billing and Audit. That menu is built in app/composables/useOrgNav.ts(Licensees only), so add your entry there. /o/** renders on the client only.

The client helpers:

  • useOrgs() returns orgs (every organization the account is in), roots, pending, error and orgless, which is true once the list loaded and was empty.
  • useOrgRole(publicId) returns role, can(permission) and member, which stays null until the list loads. Use it to hide controls; the route's requireOrgScope is what enforces.
  • useActiveOrg() returns active: the organization in the URL, or else the one the account last switched to. /dashboard redirects there.
  • useStarterOrgs() exposes the config: enabled, levels, roles, ownerRole, levelFor(isChild).

After a mutation of your own, invalidate the ['orgs'] query key prefix. The list, the landing organization and the member counts all live under it.

Roles and org permissions

Org roles are separate from app roles (Roles and permissions). The shipped set:

orgs: {
  permissions: [
    'org:manage',
    'org:members:manage',
    'org:billing',
    'org:scopes:manage',
  ],

  roles: {
    owner: {
      permissions: [
    'org:manage',
    'org:members:manage',
    'org:billing',
    'org:scopes:manage',
  ],
      at: ['organization'],
    },
    admin: {
      permissions: ['org:manage', 'org:members:manage', 'org:scopes:manage'],
      at: ['organization'],
    },
    member: { permissions: [], at: ['organization'] },
  },

  defaultRole: 'member',
  ownerRole: 'owner',
},
PermissionOpens
org:manageRename, photo, the audit tab and its chart, closing
org:members:manageInviting, revoking invitations, changing roles, removing
org:billingThe billing tab, checkout for the organization, the portal
org:scopes:manageCreating branches, when a second level is declared

Any member can read the roster. ownerRole is the role an organization's creator gets and the one the last-owner guard counts. defaultRole is what the invitation form preselects. at lists the levels a role may be held at.

A member can only grant, change or remove a role whose permissions their own roles all hold. The shipped admin lacks org:billing, so it can't make anyone an owner, itself included, or demote one. The refusal is 403 with data.reason role-beyond-yours or member-outranks-you. The check is roleCoveredBy in shared/utils/org-permissions.ts(Licensees only), so a role you add follows the same rule.

To add a role, add its key to orgs.roles and its label as dashboard.orgs.roles.<id> in every file in i18n/locales/. test/unit/org-copy.test.ts(Licensees only) fails on a missing label. To add a permission, list it in orgs.permissions, grant it to roles, and pass it to requireOrgScope.

Org permissions are never sold; a plan only sets how many people an organization holds. The build fails if a string in orgs.permissions also appears in access.planPermissions.

Add a second level

Two levels give each organization child scopes that own rows: branches, sites or projects. Declare the level and let the owner role be held there:

orgs: {
  levels: ['organization', 'branch'],

  roles: {
    owner: {
      permissions: [
    'org:manage',
    'org:members:manage',
    'org:billing',
    'org:scopes:manage',
  ],
      at: ['organization', 'branch'],
    },
    manager: {
      permissions: ['org:manage', 'org:members:manage'],
      at: ['branch'],
    },
    member: { permissions: [], at: ['organization', 'branch'] },
  },
},

Then add the level's copy to every locale: dashboard.orgs.levels.branch, levelsPlural.branch, switcher.manage.branch and scopes.action.branch under dashboard.orgs. General gains a branches card on each root.

  • POST /api/orgs with { name, parent } creates a branch. It needs org:scopes:manage at the parent, and the parent must be a root (422 otherwise). Its creator becomes the branch's owner.
  • A scope at a branch unions the caller's roles at the branch and at its root, so the company's owners and admins run every branch without joining it.
  • A branch uses its root's plan and seats. If each unit should pay on its own, make them roots.
  • A membership at a level its role's at doesn't list answers 422 role-not-allowed-here.

The build refuses a third level, and a second level the owner role can't be held at.

Seats

orgs.seatLimits sets how many people each plan admits, with false for no limit and free for an organization with no grant. When a root holds several live grants, the highest limit wins. A seat is a person anywhere in the root and its branches, so an owner who also belongs to three branches takes one.

Every membership is created through admitMember in server/tenancy/seats.ts(Licensees only), which counts and inserts inside one serializable transaction. A full organization answers 402 with data.reason: 'seats-full'. GET /api/orgs/:org/members returns the roster with seats: { used, invited, limit }, where limit: null means no limit.

Invitations

  1. A member with org:members:manage posts { email, role } to POST /api/orgs/:org/invitations. The row stores a hash of the token, and the plaintext travels only on the org:invitation:created event.
  2. server/listeners/00-org-invitation-mail.ts(Licensees only) enqueues an org-invitation-email job, or sends inline without a queue. The mail links to /invite?token=….
  3. The invitee signs in or registers, keeping the link, and the page posts to POST /api/orgs/invitations/accept.

A pending invitation takes no seat, but issuing one is refused with 402 once members plus open invitations fill the plan. Acceptance checks the seat again. Accepting from an account whose address differs from the invited one answers 409 invitation-other-address, naming both addresses, until the request sends acceptAsCurrentUser: true. Acceptance also refuses when the person who sent the invitation has since lost the right to send it.

An account whose address is not proven yet (Auth) gets 403 data.reason: 'address-unverified' when it issues an invitation, changes a member's role (PATCH /api/orgs/:org/members/:userId) or accepts an invitation sent to another address. Accepting one sent to its own address, revoking, removing members and creating an organization stay open. A route of yours scoped with requireOrgScope passes { provenAddress: true } as the fourth argument to refuse the same way.

Limits on issuing: maxPending live invitations per organization (429), and the rate limits org-invite (10 an hour per address, a minute apart) and org-invite-sender (50 an hour per inviter). Revoking is DELETE /api/orgs/:org/invitations/:id. The nightly invitations:prune task deletes invitations 30 days after they expire, whatever became of them.

The invite route answers 500 when the deploy has no site URL to build the link from (NUXT_SITE_URL). Set orgs.invitations: false to remove the four invitation routes and add members your own way. With invitations on, the build requires email.enabled.

Leave, remove and close

  • Leaving and removing share DELETE /api/orgs/:org/members/:userId. Your own id leaves; anyone else's needs org:members:manage. Removing someone from a root also removes them from its branches.
  • The last holder of ownerRole at a root can't leave or be demoted: 409 with data.reason: 'last-owner'. Make someone else an owner first.
  • DELETE /api/orgs/:org closes an organization. It needs org:manage, and on a root the caller's roles must also cover ownerRole. The close is soft: it sets deleted_at on the organization and its branches, and every read skips them. There is no reopen route; clear deleted_at by hand.
  • A close answers 409 live-subscription while the provider can still bill a subscription, so cancel first. An active one-time purchase answers 409 lifetime-purchase with the plans it would forfeit, until the body sends { "forfeitPurchases": true }.
  • Deleting an account answers 409 sole-owner, listing the organizations, while it is the only owner of a root that has other people in it.

React to organization events

Tenancy fires eight events on the bus (Events), each after its transaction commits:

EventPayload
org:createdorgId, publicId, name, parentId, createdByUserId
org:deletedorgId, rootOrgId, actorUserId
org:member:addedorgId, rootOrgId, userId, role, source, actorUserId
org:member:removedorgId, rootOrgId, userId, reason (removed or left), actorUserId
org:member:roleChangedorgId, rootOrgId, userId, from, to, actorUserId
org:invitation:createdorgId, rootOrgId, invitationId, email, role, actorUserId, token
org:invitation:acceptedorgId, rootOrgId, invitationId, email, userId
org:invitation:revokedorgId, invitationId, actorUserId

The invitation mail is the only listener the kit ships; notifying people about the rest is up to you. server/listeners/unassign-removed-member.ts:

import { and, eq } from 'drizzle-orm'

import { defineListener } from '#server/events/types'

export default defineListener({
  event: 'org:member:removed',

  async handle({ orgId, userId }, { event }) {
    await useDrizzle(event)
      .delete(tables.projectAssignees)
      .where(
        and(
          eq(tables.projectAssignees.orgId, orgId),
          eq(tables.projectAssignees.userId, userId),
        ),
      )
  },
})

Closing an organization because its last member erased their account fires no org:deleted. That path fires only account:purged.

Staff directory

/dashboard/organizations lets app staff list and search every organization, including closed ones on request, and open one to see its members, branches, grants and seats. Viewing needs users:read; renaming from there needs users:manage, and the organization's audit trail shows it as done by an administrator. Staff aren't members, so these routes check requirePermission instead of requireOrgScope. They exist only while organizations are on.

Turn organizations off

Set orgs.enabled: false. The organization routes and pages are not registered, and the account menu drops its organization entries. The tables stay. Payments keep working: a purchase lands on the account's own organization, created at the first purchase, and plan checks read that organization without being told which. Organizations need auth.enabled, and the build fails otherwise.

Configuration

KeyDefaultWhat it does
orgs.enabledtrueRegisters the organization routes and pages.
orgs.levels['organization']One or two level ids, root first. Each keys its labels.
orgs.createAtSignuptrueGives every new account an organization it owns.
orgs.permissionsthe four org:* permissionsEvery capability an org role may hold. Never sold.
orgs.rolesowner, admin, memberEach role's permissions and the levels (at) it may be held at.
orgs.defaultRole'member'The role the invitation form preselects.
orgs.ownerRole'owner'The creator's role; the last-owner guard counts it. Must be holdable at every level.
orgs.invitationsan objectfalse removes invitations.
orgs.invitations.expiryHours336Hours an invitation can be accepted.
orgs.invitations.maxPending50Live invitations per organization.
orgs.seatLimitsfree 3, pro 10, promax unlimited, lifetime 10People each plan admits. false is no limit.

The only environment variable involved is NUXT_SITE_URL, which invitation links are built from.

Good to know

  • requireOrgScope answers 404 to a non-member on purpose, so a public id can't be probed for existence. Keep that in your own routes.
  • The client knows one role per organization, from GET /api/orgs, while the server unions root and branch roles. At a branch useOrgRole can hide a control the server would allow.
  • A closed organization grants nothing, but its members, invitations, audit trail and grants stay in the database.
  • org_audit_log has no foreign key to users and keeps names as text, so an organization's trail survives a member erasing their account. Account erasure doesn't touch it.
  • A role you remove from orgs.roles stays in org_members.role and grants nothing. Invitations for it stop working. Migrate the rows yourself.

A link with a lock opens the kit’s private repository, which every licensee can read.