noboil

Organizations

Multi-tenant apps with roles, invites, join requests, and per-item ACL.

A complete multi-tenant system: orgs, members, roles, invites, join requests, per-item ACL — generated from one schema.

Schema setup

import { schema, orgSchema } from 'noboil/convex/schema'
import { object, string } from 'zod/v4'

export const s = schema({
  org: {
    team: orgSchema
  },
  orgScoped: {
    project: object({ description: string().optional(), name: string().min(1) }),
    wiki: object({ title: string().min(1), slug: string(), status: string() })
  }
})

org.team is the org definition (name, slug, avatar). orgScoped schemas auto-get userId, orgId, and updatedAt system fields.

Wire it up

const api = noboil({
  query, mutation, action, internalQuery, internalMutation, getAuthUserId, orgSchema: s.team, orgCascadeTables: ['project', 'wiki'],
  tables: ({ table }) => ({
    project: table(s.project, { acl: true }),
    wiki: table(s.wiki, { acl: true, softDelete: true })
  })
})

noboil() auto-creates the orgMember, orgInvite, and orgJoinRequest sub-tables. Org-scoped tables cascade-delete when an org is removed.

One-line org-scoped CRUD

Each orgScoped table generates:

  • create_X, update_X, rm_X — auth + org membership + ownership checks
  • list, read — scoped to the active org
  • addEditor, removeEditor, setEditors, editors — when acl: true
  • restore — when softDelete: true

The create reducer checks org membership before inserting. update and rm check that the caller is either an org admin or the original creator.

Generated org API

OperationEndpoint
Create orgapi.org.create
Update orgapi.org.update
Delete orgapi.org.remove
Get by IDapi.org.get
Get by slugapi.org.getBySlug
List my orgsapi.org.myOrgs
Membership infoapi.org.membership
List membersapi.org.members
Set adminapi.org.setAdmin
Remove memberapi.org.removeMember
Leaveapi.org.leave
Transfer ownershipapi.org.transferOwnership
Inviteapi.org.invite
Accept inviteapi.org.acceptInvite
Revoke inviteapi.org.revokeInvite
Pending invitesapi.org.pendingInvites
Request to joinapi.org.requestJoin
Approve joinapi.org.approveJoinRequest
Reject joinapi.org.rejectJoinRequest
Pending join requestsapi.org.pendingJoinRequests

Client-side org context

Wrap your layout with OrgProvider. Components inside read the active org via useOrg, scope queries with useOrgQuery, and call mutations with useOrgMutationorgId is injected automatically.

import { OrgProvider, useOrg, useOrgQuery, useOrgMutation, useActiveOrg, useMyOrgs } from 'noboil/convex/react'

<OrgProvider org={org} role={role} membership={membership}>
  {children}
</OrgProvider>

const { org, role, isAdmin, isOwner } = useOrg()
const { activeOrg, setActiveOrg } = useActiveOrg()
const { orgs } = useMyOrgs()

const projects = useOrgQuery(api.project.list, { paginationOpts: { cursor: null, numItems: 20 } })
const remove = useOrgMutation(api.project.rm)
await remove({ id: projectId })

Typed bindings with createOrgHooks

import { createOrgHooks } from 'noboil/convex/react'

const { useOrg, useActiveOrg, useMyOrgs, useOrgQuery, useOrgMutation } = createOrgHooks(api.org)

ACL — per-item editor permissions

acl: true adds addEditor, removeEditor, setEditors, editors endpoints, plus an editors[] field on each row.

RoleCan edit?
Org owner / adminAlways
Item creatorAlways (own docs)
In editors[]Yes
Regular memberView only
table(s.wiki, { acl: true, softDelete: true })

Use canEditResource to gate UI:

import { canEditResource } from 'noboil/convex/react'

const canEdit = canEditResource({
  resource: wiki,
  editorsList: wiki.editors ?? [],
  isAdmin: org.isAdmin,
  userId: currentUserId
})

Child tables can inherit ACL from their parent:

table(s.task, { aclFrom: { field: 'projectId', table: 'project' } })

Cascade delete

import { orgCascade } from 'noboil/convex/server'

table(s.project, {
  acl: true,
  cascade: orgCascade(s.task, { foreignKey: 'projectId', table: 'task' })
})

Configure org-level cascade with file cleanup in noboil() config:

noboil({
  // ...
  orgSchema: s.team,
  orgCascadeTables: [
    'project',
    'wiki',
    { table: 'attachment', fileFields: ['fileId'] }
  ],
  tables: ({ table }) => ({ /* ... */ })
})

File fields are cleaned from storage before rows are deleted. Members, invites, and join requests are purged. The org document is deleted last.

Invite and join lifecycle

Path 1 — admin invites a user:

admin → invite(email, orgId)
  → Invite record created with token
  → user → acceptInvite(token)
    → orgMember row created
    → invite marked used
await invite({ email: 'alice@company.com', orgId: org.id })
const pending = await pendingInvites({ orgId: org.id })
await revokeInvite({ inviteId: pending[0].id, orgId: org.id })

Path 2 — user requests to join:

user → requestJoin(orgId, message?)
  → JoinRequest created (status: 'pending')
  → admin → approveJoinRequest(requestId, orgId)  OR  rejectJoinRequest(...)
    → orgMember row created (on approve)
await requestJoin({ orgId: org.id, message: 'I work on the frontend team' })
const requests = await pendingJoinRequests({ orgId: org.id })
await approveJoinRequest({ orgId: org.id, requestId: requests[0].id })

Invite tokens are 24 random bytes (crypto.getRandomValues()) encoded as a 32-char base-36 string.

Org switching

The active org is stored as a cookie so server components can read it.

import { setActiveOrgCookie, getActiveOrg, clearActiveOrgCookie } from 'noboil/convex/next'

await setActiveOrgCookie(orgId)
const activeOrg = await getActiveOrg()
await clearActiveOrgCookie()
import { setActiveOrgCookieClient } from 'noboil/convex/react'

setActiveOrgCookieClient(orgId)

Roles

RolePermissions
ownerAll permissions. Transfer ownership. Delete org.
adminManage members. Invite/remove. Approve join requests. Full CRUD.
memberCRUD own resources within the org. Leave org.

Handling permission errors

import { handleError } from 'noboil/convex/server'

try {
  await createProject({ name, orgId: org.id })
} catch (e) {
  handleError(e, {
    NOT_ORG_MEMBER: () => router.push('/orgs'),
    INSUFFICIENT_ORG_ROLE: () => toast.error('Admin access required'),
    EDITOR_REQUIRED: () => toast.error('You need editor permission'),
    RATE_LIMITED: ({ retryAfter }) => toast.error(`Slow down — retry in ${retryAfter}ms`),
    default: () => toast.error('Something went wrong')
  })
}

Pre-built components

import {
  EditorsSection,
  PermissionGuard,
  OrgAvatar,
  RoleBadge,
  OfflineIndicator
} from 'noboil/convex/components'

See Components for the full reference.

On this page