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
- Four tables hold it:
organizations,org_members,org_invitationsandorg_audit_log. They ship on every build.orgs.enabledadds or removes routes and pages, never tables. - An organization is addressed by its
public_id, 16 lowercase characters such as3k9x0v7q2m8d1c4f. URLs and API routes use it. The integer id stays on the server. - The organization a request acts on comes from the URL, never from the
session.
requireOrgScoperesolves it and reads the caller's roles on every request, so a role change or a removal applies on the next one. - Each signup gets an organization of its own with the new account as owner,
while
orgs.createAtSignupis on. - Plans and seats belong to the root organization. Checkout and the billing
portal take the organization by public id and need
org:billingthere (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()returnsorgs(every organization the account is in),roots,pending,errorandorgless, which is true once the list loaded and was empty.useOrgRole(publicId)returnsrole,can(permission)andmember, which staysnulluntil the list loads. Use it to hide controls; the route'srequireOrgScopeis what enforces.useActiveOrg()returnsactive: the organization in the URL, or else the one the account last switched to./dashboardredirects 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',
},
| Permission | Opens |
|---|---|
org:manage | Rename, photo, the audit tab and its chart, closing |
org:members:manage | Inviting, revoking invitations, changing roles, removing |
org:billing | The billing tab, checkout for the organization, the portal |
org:scopes:manage | Creating 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/orgswith{ name, parent }creates a branch. It needsorg:scopes:manageat 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
atdoesn't list answers 422role-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
- A member with
org:members:manageposts{ email, role }toPOST /api/orgs/:org/invitations. The row stores a hash of the token, and the plaintext travels only on theorg:invitation:createdevent. server/listeners/00-org-invitation-mail.ts(Licensees only) enqueues anorg-invitation-emailjob, or sends inline without a queue. The mail links to/invite?token=….- 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 needsorg:members:manage. Removing someone from a root also removes them from its branches. - The last holder of
ownerRoleat a root can't leave or be demoted: 409 withdata.reason: 'last-owner'. Make someone else an owner first. DELETE /api/orgs/:orgcloses an organization. It needsorg:manage, and on a root the caller's roles must also coverownerRole. The close is soft: it setsdeleted_aton the organization and its branches, and every read skips them. There is no reopen route; cleardeleted_atby hand.- A close answers 409
live-subscriptionwhile the provider can still bill a subscription, so cancel first. An active one-time purchase answers 409lifetime-purchasewith theplansit 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:
| Event | Payload |
|---|---|
org:created | orgId, publicId, name, parentId, createdByUserId |
org:deleted | orgId, rootOrgId, actorUserId |
org:member:added | orgId, rootOrgId, userId, role, source, actorUserId |
org:member:removed | orgId, rootOrgId, userId, reason (removed or left), actorUserId |
org:member:roleChanged | orgId, rootOrgId, userId, from, to, actorUserId |
org:invitation:created | orgId, rootOrgId, invitationId, email, role, actorUserId, token |
org:invitation:accepted | orgId, rootOrgId, invitationId, email, userId |
org:invitation:revoked | orgId, 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
| Key | Default | What it does |
|---|---|---|
orgs.enabled | true | Registers the organization routes and pages. |
orgs.levels | ['organization'] | One or two level ids, root first. Each keys its labels. |
orgs.createAtSignup | true | Gives every new account an organization it owns. |
orgs.permissions | the four org:* permissions | Every capability an org role may hold. Never sold. |
orgs.roles | owner, admin, member | Each 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.invitations | an object | false removes invitations. |
orgs.invitations.expiryHours | 336 | Hours an invitation can be accepted. |
orgs.invitations.maxPending | 50 | Live invitations per organization. |
orgs.seatLimits | free 3, pro 10, promax unlimited, lifetime 10 | People 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
requireOrgScopeanswers 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 branchuseOrgRolecan 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_loghas no foreign key tousersand 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.rolesstays inorg_members.roleand 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.