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 checkslist,read— scoped to the active orgaddEditor,removeEditor,setEditors,editors— whenacl: truerestore— whensoftDelete: 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
| Operation | Endpoint |
|---|---|
| Create org | api.org.create |
| Update org | api.org.update |
| Delete org | api.org.remove |
| Get by ID | api.org.get |
| Get by slug | api.org.getBySlug |
| List my orgs | api.org.myOrgs |
| Membership info | api.org.membership |
| List members | api.org.members |
| Set admin | api.org.setAdmin |
| Remove member | api.org.removeMember |
| Leave | api.org.leave |
| Transfer ownership | api.org.transferOwnership |
| Invite | api.org.invite |
| Accept invite | api.org.acceptInvite |
| Revoke invite | api.org.revokeInvite |
| Pending invites | api.org.pendingInvites |
| Request to join | api.org.requestJoin |
| Approve join | api.org.approveJoinRequest |
| Reject join | api.org.rejectJoinRequest |
| Pending join requests | api.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 useOrgMutation — orgId 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.
| Role | Can edit? |
|---|---|
| Org owner / admin | Always |
| Item creator | Always (own docs) |
In editors[] | Yes |
| Regular member | View 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 usedawait 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
| Role | Permissions |
|---|---|
owner | All permissions. Transfer ownership. Delete org. |
admin | Manage members. Invite/remove. Approve join requests. Full CRUD. |
member | CRUD 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.