Features
Access Control
Overview
Vextro includes a complete RBAC (Role-Based Access Control) system. Tables for users, roles, permissions, and their join relationships are auto-generated by createVextroSchema. Default permissions and roles are provided out of the box. The admin module auto-injects CRUD functions for managing users, roles, and permissions, and the admin sidebar includes dedicated Access Control pages.
There are three tiers of usage:
- Zero config --
createVextroSchemagenerates RBAC tables andcreateVextroAdminModuleauto-injects default permissions and roles. No setup required. - Extend defaults -- Import
defaultPermissionsanddefaultRoles, spread them, and add your own. - Full control -- Provide your own permissions, roles, table names, and user fields. Or disable auto-generation entirely and manage your own tables.
Quick start (zero config)
With no accessControl configuration, Vextro generates all RBAC tables and injects sensible defaults. This is all you need:
// convex/schema.ts
import { defineSchema } from "convex/server";
import { createVextroSchema } from "vextro/convex/schema";
import { posts, pages, media } from "./collections";
const vextro = createVextroSchema({
collections: [posts, pages, media],
globals: [],
// accessControl is omitted — tables auto-generated with defaults
});
export { vextro };
export default defineSchema({
...vextro.tables,
// Your own non-admin tables here
}); // convex/admin.ts
import { query, mutation } from "./_generated/server";
import { components } from "./_generated/api";
import { createVextroAdminModule } from "vextro/convex/admin";
import { vextro } from "./schema";
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
globalDefinitions: vextro.globalDefinitions,
// accessControl is omitted — default permissions/roles auto-injected
});
export const {
listCollections,
getCollectionBySlug,
seedAdminMetadata,
// ... all other exports including access control functions
listRoles,
listPermissions,
listUsers,
me,
createRole,
assignRoleToUser,
syncAccessControl,
} = admin; This gives you:
- Six auto-generated tables:
users,roles,permissions,userRoles,rolePermissions,userScopes - 11 default permissions covering user management, CMS, and media
- 4 default roles (Administrator, Editor, Author, Viewer) with pre-assigned permissions
- 23 RBAC functions for managing users, roles, and scopes
- Access Control pages in the admin sidebar (Users, Roles, Permissions)
Sync on first load
Default permissions and roles are synced to the database automatically when the admin shell loads. You can also trigger a manual sync by calling syncAccessControl.
Default permissions
Vextro ships 11 default permissions organized into three groups:
| Slug | Description | Group |
|---|---|---|
users:read | View user list and details | User management |
users:manage | Create, update, delete users and assign roles | User management |
roles:read | View roles and permissions | Role management |
roles:manage | Create, update, delete roles and permissions | Role management |
cms:read | View CMS content | CMS content |
cms:edit | Create and edit CMS content | CMS content |
cms:publish | Publish and unpublish content | CMS content |
cms:admin | Full CMS admin access including settings | CMS content |
media:read | View media library | Media |
media:upload | Upload media files | Media |
media:manage | Manage and delete media files | Media |
Default roles
Each default role is pre-assigned a subset of the default permissions:
| Slug | Name | Permissions |
|---|---|---|
admin | Administrator | All 11 permissions |
editor | Editor | cms:read, cms:edit, cms:publish, media:read, media:upload |
author | Author | cms:read, cms:edit, media:read, media:upload |
viewer | Viewer | cms:read, media:read |
Extending defaults
Import defaultPermissions and defaultRoles and spread them alongside your own:
// convex/admin.ts
import { createVextroAdminModule } from "vextro/convex/admin";
import { defaultPermissions, defaultRoles } from "vextro/convex/accessControl";
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
accessControl: {
permissions: [
...defaultPermissions,
{ slug: "orders:read", description: "View orders" },
{ slug: "orders:manage", description: "Create, update, delete orders" },
{ slug: "reports:view", description: "View analytics reports" },
],
roles: [
...defaultRoles,
{
slug: "sales-rep",
name: "Sales Representative",
description: "Order management and reporting",
permissions: ["cms:read", "orders:read", "orders:manage", "reports:view"],
},
{
slug: "analyst",
name: "Analyst",
description: "Read-only access with reports",
permissions: ["cms:read", "reports:view"],
},
],
},
}); Additive sync
Syncing is additive -- permissions and roles defined in code are created if missing, but manual assignments made through the admin UI are never removed. You can safely add new permissions and roles in code without losing existing user-role assignments.
Auth-driven user fields
When using defineVextroAuth, provider-specific fields are automatically injected into the users table schema. You don't need to manually add oktaSub, googleId, etc.
import { defineVextroAuth, okta, google } from "vextro/convex/authProviders";
export const authConfig = defineVextroAuth({
appName: "My Admin",
providers: [
okta({ issuer: process.env.OKTA_ISSUER!, clientId: process.env.OKTA_CLIENT_ID!, clientSecret: process.env.OKTA_CLIENT_SECRET! }),
google({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
],
});
// In schema.ts — provider fields auto-injected
import { createVextroSchema } from "vextro/convex/schema";
const vextro = createVextroSchema({
collections: allCollections,
globals: [],
accessControl: {
userFields: authConfig.providerMeta.userFields,
userIndexes: authConfig.providerMeta.userIndexes,
},
}); Provider presets inject these fields:
| Provider | Field | Index |
|---|---|---|
okta() | oktaSub | by_oktaSub |
google() | googleId | by_googleId |
github() | githubId | by_githubId |
microsoft() | microsoftId | by_microsoftId |
oidc() | oidcSub | by_oidcSub |
emailPassword() | (none) | (none) |
magicLink() | (none) | (none) |
Consumer fields in accessControl.userFields merge on top -- if you override a provider field (e.g., oktaSub: v.string()), your definition takes precedence.
For custom providers, use defineProvider:
import { v } from "convex/values";
import { defineProvider } from "vextro/convex/authProviders";
const saml = defineProvider({
id: "saml",
userFields: { samlNameId: v.optional(v.string()) },
userIndexes: [{ name: "by_samlNameId", fields: ["samlNameId"] }],
}); Custom user fields
The auto-generated users table includes email, displayName, avatarUrl, status, lastLoginAt, and tokenIdentifier by default. To add custom fields, use accessControl.userFields on createVextroSchema:
// convex/schema.ts
import { v } from "convex/values";
import { createVextroSchema } from "vextro/convex/schema";
const vextro = createVextroSchema({
collections: [posts, pages, media],
globals: [],
accessControl: {
userFields: {
phone: v.optional(v.string()),
department: v.optional(v.string()),
hireDate: v.optional(v.number()),
externalId: v.optional(v.string()),
},
userIndexes: [
{ name: "by_department", fields: ["department"] },
{ name: "by_externalId", fields: ["externalId"] },
],
},
});
export default defineSchema({ ...vextro.tables }); Custom fields are merged into the users table definition alongside the defaults. Custom indexes are added to the users table as well.
Profile editing
By default, users can edit their own displayName and avatarUrl through the admin UI. To allow self-service editing of additional fields, pass userProfileFields to createVextroAdminModule:
const admin = createVextroAdminModule({
// ...
userProfileFields: ["displayName", "avatarUrl", "phone", "department"],
}); Overriding table names
If your schema already has tables named users or roles, or you prefer different naming, override the RBAC table names in both createVextroSchema and createVextroAdminModule:
// convex/schema.ts
const vextro = createVextroSchema({
collections: [posts, pages, media],
globals: [],
accessControl: {
tables: {
users: "app_users",
roles: "app_roles",
permissions: "app_permissions",
userRoles: "user_role_assignments",
rolePermissions: "role_permission_grants",
userScopes: "user_scope_assignments",
},
},
}); // convex/admin.ts
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
accessControl: {
permissions: [...defaultPermissions],
roles: [...defaultRoles],
tables: {
users: "app_users",
roles: "app_roles",
permissions: "app_permissions",
userRoles: "user_role_assignments",
rolePermissions: "role_permission_grants",
userScopes: "user_scope_assignments",
},
},
}); | Key | Default | Description |
|---|---|---|
users | "users" | Users table |
roles | "roles" | Roles table |
permissions | "permissions" | Permissions table |
userRoles | "userRoles" | User-role join table |
rolePermissions | "rolePermissions" | Role-permission join table |
userScopes | "userScopes" | User-scope assignments table |
Keep names in sync
Table name overrides must match between createVextroSchema (which generates the table definitions) and createVextroAdminModule (which queries those tables). Mismatched names will cause runtime errors.
Permission strings on collections
Use the access property on defineVextroCollection to gate CRUD operations by permission. The strings are matched against the user's resolved role slugs (from getUserRoles):
import { defineVextroCollection, f } from "vextro";
export const products = defineVextroCollection({
label: "Products",
collectionType: "content",
tableName: "products",
access: {
read: "cms:read",
create: "cms:edit",
update: "cms:edit",
delete: "cms:admin",
},
fields: {
name: f.text({ required: true }),
price: f.number({ required: true }),
category: f.select({
options: ["electronics", "clothing", "food"],
required: true,
}),
},
}); | Property | Type | Description |
|---|---|---|
read | string | Role or permission required to list and view documents |
create | string | Role or permission required to create documents |
update | string | Role or permission required to update documents (also covers bulk status, bulk tags, version restore) |
delete | string | Role or permission required to delete documents (also covers bulk delete) |
Operations without a restriction are allowed for all authenticated users (who pass requireAdminRead/requireAdminWrite).
The admin UI applies the same collection access flags to its editing affordances. Users without create access do not see the collection's New or template controls, and the N shortcut cannot open the new-document route. On a direct new-document URL, the editor remains read-only when create access is denied. Users without update access likewise receive a read-only editor for existing documents.
Partial restrictions
You can restrict some operations while leaving others open:
access: {
// Anyone with admin access can read
// Only content-editors can create and update
create: "cms:edit",
update: "cms:edit",
// Only admins can delete
delete: "cms:admin",
}, Which mutations are gated
| Mutation | Access check |
|---|---|
createDocumentForCollection | create |
duplicateDocumentForCollection | create |
bulkCreateDocuments | create |
updateDocumentForCollection | update |
bulkUpdateStatus | update |
bulkUpdateTags | update |
restoreFieldsFromVersion | update |
deleteDocumentForCollection | delete |
bulkDeleteDocuments | delete |
Which queries are gated
| Query | Access check |
|---|---|
getCollectionPageData | read |
getCollectionPageDataPaginated | read |
listDocumentsForCollection | read |
paginateDocuments | read |
getDocumentForCollection | read |
getDocumentEditPageData | read |
Global-level access
Globals support read and update (globals cannot be created or deleted):
import { defineVextroGlobal, f } from "vextro";
export const siteSettings = defineVextroGlobal({
slug: "site-settings",
label: "Site Settings",
tableName: "site_settings",
access: {
read: "cms:read",
update: "cms:admin",
},
fields: {
siteName: f.text({ required: true }),
maintenanceMode: f.checkbox(),
},
}); Field-level permissions
Individual fields can be restricted by role. Use readRoles and writeRoles in the field's config:
fields: {
name: f.text({ required: true }),
salary: f.number({
config: {
readRoles: ["admin", "hr-manager"],
writeRoles: ["admin"],
hidden: true,
},
}),
internalNotes: f.textarea({
config: {
writeRoles: ["admin", "editor"],
},
}),
} | Option | Type | Description |
|---|---|---|
readRoles | string[] | Roles that can see this field. Users without a matching role see the field as read-only, or hidden if hidden: true. |
writeRoles | string[] | Roles that can edit this field. Users without a matching role see the field as read-only. |
hidden | boolean | When true, the field is completely hidden from users who lack readRoles. Default: false (field is visible but read-only). |
How field permissions work
In queries (field annotations):
- Vextro calls
annotateFieldsForUser()with the field list and user roles. - Each field is checked for
readRolesandwriteRoles. - If the user lacks write access, the field is annotated with
isReadOnly: true. - If the user lacks read access AND
hidden: true, the field is annotated withisHidden: true. - The admin UI renders these annotations accordingly.
In mutations (write stripping):
- Vextro calls
getWriteRestrictedFieldNames()to identify fields the user cannot write. stripRestrictedFields()silently removes those fields from the payload before persisting.- The user cannot modify restricted fields, even by crafting manual API calls.
Interaction with collection-level access
Field permissions are independent of collection-level access. Both are checked:
- Collection
access.update: "cms:edit"gates the entire update operation - Field
writeRoles: ["admin"]further restricts specific fields within that operation
A user with the "editor" role can update the collection, but fields restricted to "admin" are silently stripped from their patches.
Policy helpers
Vextro provides a createPolicyHelpers factory that generates a complete set of RBAC helper functions for your custom Convex functions. These helpers resolve user identity from auth tokens, attach roles and permissions from the database, and enforce access requirements.
// convex/lib/policy.ts
import { createPolicyHelpers } from "vextro/convex/policy";
export const {
getCurrentUser,
attachRolesAndPermissions,
requireAuth,
requireActiveUser,
hasRole,
hasAnyRole,
hasAllRoles,
requireRole,
requireAnyRole,
hasPermission,
hasAnyPermission,
hasAllPermissions,
requirePermission,
requireAnyPermission,
AuthorizationError,
} = createPolicyHelpers(); If your tables use different names, pass a config object:
const policy = createPolicyHelpers({
usersTable: "app_users",
rolesTable: "app_roles",
permissionsTable: "app_permissions",
userRolesTable: "user_role_assignments",
rolePermissionsTable: "role_permission_grants",
}); Using policy helpers in custom functions
// convex/myFunctions.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import {
requirePermission,
requireAnyRole,
getCurrentUser,
hasPermission,
} from "./lib/policy";
// Guard an entire function
export const deleteOrder = mutation({
args: { orderId: v.id("orders") },
returns: v.null(),
handler: async (ctx, args) => {
// Throws AuthorizationError if user lacks permission
await requirePermission(ctx, "orders:manage");
await ctx.db.delete(args.orderId);
return null;
},
});
// Conditional logic based on permissions
export const getOrderDetails = query({
args: { orderId: v.id("orders") },
returns: v.any(),
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
if (!user) return null;
const order = await ctx.db.get(args.orderId);
if (!order) return null;
// Strip sensitive fields for non-admins
if (!hasPermission(user, "orders:manage")) {
const { internalCost, supplierNotes, ...publicFields } = order;
return publicFields;
}
return order;
},
}); Wiring policy helpers with the admin module
The policy helpers integrate naturally with createVextroAdminModule to provide role-based admin access:
// convex/admin.ts
import { createVextroAdminModule } from "vextro/convex/admin";
import {
getCurrentUser,
requireAnyPermission,
requirePermission,
} from "./lib/policy";
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
getCurrentUser,
requireAdminRead: async (ctx) => {
await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);
},
requireAdminWrite: async (ctx) => {
await requirePermission(ctx, "cms:admin");
},
getUserRoles: async (ctx) => {
const user = await getCurrentUser(ctx);
if (!user) return [];
// Return both role slugs and permission strings
return [
...user.roles.map((r) => r.slug),
...user.permissions,
];
},
}); For the full policy helpers API reference, see Policy Helpers.
Disabling auto-generated tables
If your project manages its own users, roles, and permissions tables -- or you use an external identity provider -- disable Vextro's auto-generation:
// convex/schema.ts
const vextro = createVextroSchema({
collections: [posts, pages, media],
globals: [],
accessControl: {
enabled: false,
},
}); With enabled: false:
- No RBAC tables are added to
vextro.tables - You must define your own users/roles/permissions tables in your schema
- You must provide
getCurrentUserandgetUserRolescallbacks to the admin module - The admin Access Control pages still work if you provide
accessControlconfig tocreateVextroAdminModulewith the correcttablesmapping
// convex/admin.ts
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
getCurrentUser: async (ctx) => {
// Your own user lookup logic
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("members")
.withIndex("by_authId", (q) => q.eq("authId", identity.subject))
.unique();
},
getUserRoles: async (ctx) => {
const user = await getCurrentUser(ctx);
if (!user) return [];
// Return role slugs from your own tables
return user.roleSlugs;
},
accessControl: {
permissions: [
{ slug: "cms:read", description: "View content" },
{ slug: "cms:write", description: "Edit content" },
],
roles: [
{
slug: "admin",
name: "Admin",
permissions: ["cms:read", "cms:write"],
},
],
tables: {
users: "members",
roles: "member_roles",
permissions: "member_permissions",
userRoles: "member_role_assignments",
rolePermissions: "member_role_permissions",
userScopes: "member_scopes",
},
},
}); Built-in UI
When access control is configured, Vextro adds an Access Control section to the admin sidebar with three pages:
| Page | Path | Description |
|---|---|---|
| Users | /access/users | List all users, view details, assign roles, change status |
| Roles | /access/roles | List roles with permission and user counts, create/edit/delete roles, assign permissions |
| Permissions | /access/permissions | List all permissions with descriptions |
These pages use the 23 built-in RBAC functions generated by the admin module.
Access control auth guards
By default, the Access Control pages use the same requireAdminRead and requireAdminWrite callbacks as the rest of the admin. To require stricter auth for role and user management (for example, only super-admins can manage roles), provide separate guards:
const admin = createVextroAdminModule({
// ...
requireAdminRead: async (ctx) => {
await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);
},
requireAdminWrite: async (ctx) => {
await requirePermission(ctx, "cms:edit");
},
// Stricter guards for access control pages
requireAccessControlRead: async (ctx) => {
await requireAnyPermission(ctx, ["users:read", "roles:read", "cms:admin"]);
},
requireAccessControlWrite: async (ctx) => {
await requireAnyPermission(ctx, ["users:manage", "roles:manage"]);
},
}); Built-in RBAC functions
When accessControl is provided, the admin module includes these functions:
Role queries: listRoles, getRoleBySlug, getRoleById, getUsersForRole, listRolesWithCounts, listPermissions (list endpoints return rows as stored in Convex, including optional RBAC audit fields updatedAt / updatedBy when present). getRoleBySlug / getRoleById include the resolved permissions array plus the same optional audit fields when present.
User queries: me, getUserById, listUsers, listUsersWithRoles, getUserRolesForUser (user rows may include optional tokenIdentifier, updatedAt, and updatedBy when stored on the document; me also returns roles with optional audit fields and a flattened permissions array)
Scope queries: listUserScopes
Role mutations: createRole, updateRole, deleteRole, addPermissionToRole, removePermissionFromRole
User mutations: updateUserProfile, updateUserStatus, assignRoleToUser, removeRoleFromUser
Scope mutations: assignUserScope, removeUserScope
Sync: syncAccessControl
Export them alongside the other admin functions:
export const {
// Collection functions
listCollections,
getCollectionBySlug,
seedAdminMetadata,
// ... other collection functions
// Access control functions
listRoles,
getRoleBySlug,
getRoleById,
getUsersForRole,
listRolesWithCounts,
listPermissions,
me,
getUserById,
listUsers,
listUsersWithRoles,
getUserRolesForUser,
listUserScopes,
createRole,
updateRole,
deleteRole,
addPermissionToRole,
removePermissionFromRole,
updateUserProfile,
updateUserStatus,
assignRoleToUser,
removeRoleFromUser,
assignUserScope,
removeUserScope,
syncAccessControl,
} = admin; Session invalidation on status change
When a user's status changes (for example, deactivating an account), you may want to invalidate their active sessions so they are logged out immediately. Since session storage varies by auth provider (Convex tables, Redis, external IdP, etc.), Vextro does not invalidate sessions automatically. Instead, it provides an onUserStatusChange hook that fires after the updateUserStatus mutation completes.
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
onUserStatusChange: async (ctx, { userId, previousStatus, newStatus }) => {
if (newStatus === "inactive") {
// Delete all sessions for the deactivated user
const sessions = await ctx.db
.query("sessions")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.collect();
await Promise.all(sessions.map((s) => ctx.db.delete(s._id)));
}
},
}); | Parameter | Type | Description |
|---|---|---|
ctx | GenericMutationCtx | The Convex mutation context, with full database access |
userId | string | The ID of the user whose status changed |
previousStatus | string | The user's status before the change |
newStatus | string | The user's new status ("active", "inactive", or "pending") |
The hook runs inside the same Convex transaction as updateUserStatus, so any errors thrown will roll back the status change. If your session cleanup is non-critical (for example, sending a notification), wrap it in a try/catch to prevent rollback.
Auth provider specific
The session cleanup implementation depends entirely on your auth setup. If you use Better Auth with Convex tables, query the sessions table directly. If you use an external IdP, call their session revocation API from a scheduled action instead.
Custom labels
The sidebar labels for Access Control pages are customizable through Vextro's i18n system. Pass a locale override to rename "Users" to "Members", "Roles" to "Groups", or change the section heading:
// In your Vextro config
const config = {
locale: {
sidebar: {
accessControl: "Team Management",
users: "Members",
roles: "Groups",
permissions: "Capabilities",
},
},
}; This changes only the sidebar labels -- the underlying tables, functions, and routes remain the same.
Advanced patterns
Single auth table with linked profiles
A common pattern is to keep one users table for authentication and create separate collections for domain-specific profiles. The profile collections use a relationship field to link back to the auth user:
// Auth user table is auto-generated by createVextroSchema
// Domain-specific profile as a regular collection
export const employeeProfiles = defineVextroCollection({
label: "Employee Profiles",
collectionType: "config",
tableName: "employee_profiles",
useAsTitle: "fullName",
access: {
read: "users:read",
create: "users:manage",
update: "users:manage",
delete: "users:manage",
},
fields: {
userId: f.relationship("users", { required: true }),
fullName: f.text({ required: true }),
jobTitle: f.text(),
department: f.select({
options: ["engineering", "marketing", "sales", "operations"],
required: true,
}),
manager: f.relationship("employee_profiles"),
startDate: f.date({ required: true }),
bio: f.richText(),
},
}); This separation keeps auth concerns in the auto-generated users table (which Vextro manages) while giving you full control over profile data (which lives in a regular collection with its own access rules, hooks, and versioning).
Multi-tenancy with scopes
For multi-tenant applications, combine RBAC with scopes to control which data a user sees. Roles control what actions they can take; scopes control which documents they can access.
// Collection with scope filtering
export const storeProducts = defineVextroCollection({
label: "Store Products",
collectionType: "content",
tableName: "store_products",
scope: {
field: "regionId",
type: "region",
},
access: {
read: "cms:read",
create: "cms:edit",
update: "cms:edit",
delete: "cms:admin",
},
fields: {
name: f.text({ required: true }),
regionId: f.text({ required: true }),
price: f.number({ required: true }),
status: f.select({
options: ["draft", "published", "scheduled", "trashed"],
required: true,
}),
},
}); Provide scope resolution callbacks in the admin module:
const admin = createVextroAdminModule({
query,
mutation,
components,
collectionDefinitions: vextro.collectionDefinitions,
// ... auth callbacks
getUserScopes: async (ctx, scopeType) => {
if (scopeType !== "region") return null; // unrestricted
const user = await getCurrentUser(ctx);
if (!user) return [];
// Admins see all regions
if (hasPermission(user, "cms:admin")) return null;
// Others see only their assigned regions
const scopes = await ctx.db
.query("userScopes")
.withIndex("by_userId_and_scopeType", (q) =>
q.eq("userId", user._id).eq("scopeType", "region")
)
.collect();
return scopes.map((s) => s.scopeValue);
},
canAccessScope: async (ctx, scopeType, scopeValue) => {
if (scopeType !== "region") return true;
const user = await getCurrentUser(ctx);
if (!user) return false;
if (hasPermission(user, "cms:admin")) return true;
const scope = await ctx.db
.query("userScopes")
.withIndex("by_userId_and_scopeType", (q) =>
q.eq("userId", user._id).eq("scopeType", "region")
)
.collect();
return scope.some((s) => s.scopeValue === scopeValue);
},
listScopeOptions: async (ctx, scopeType) => {
if (scopeType !== "region") return [];
const regions = await ctx.db.query("regions").collect();
return regions.map((r) => ({
id: String(r._id),
label: r.name,
}));
},
}); | Callback | Return | When called |
|---|---|---|
getUserScopes(ctx, scopeType) | string[] or null (unrestricted) | Queries -- filters visible documents |
canAccessScope(ctx, scopeType, scopeValue) | boolean | Mutations -- validates write access to specific scope |
listScopeOptions(ctx, scopeType) | Array<{ id, label }> | Admin UI -- populates scope selector dropdown |
Assign scope access to users through the admin UI or programmatically with assignUserScope and removeUserScope.
For full scope documentation, see Scopes & Multi-Tenancy.
Custom access logic with hooks
For access patterns that do not fit the role-string model, use hooks. Hook callbacks have access to the full Convex context and can implement arbitrary logic:
export const invoices = defineVextroCollection({
label: "Invoices",
collectionType: "content",
tableName: "invoices",
hooks: {
beforeChange: [
async ({ data, ctx }) => {
// Prevent editing finalized invoices
if (data.status === "finalized") {
throw new Error("Cannot edit a finalized invoice");
}
},
],
beforeDelete: [
async ({ doc }) => {
// Only draft invoices can be deleted
if (doc.status !== "draft") {
throw new Error("Only draft invoices can be deleted");
}
},
],
},
fields: {
amount: f.number({ required: true }),
recipient: f.text({ required: true }),
status: f.select({
options: ["draft", "sent", "finalized"],
required: true,
}),
},
}); Hooks are checked after collection-level and field-level access -- they are the final gate before a write is persisted.
Error messages
When access is denied, Vextro throws descriptive errors:
- Auth callback failure: Your custom error message (e.g., "Not authenticated", "Account inactive")
- Collection access denied:
Access denied: "create" on "products" requires role "cms:edit" - Global access denied:
Access denied: "update" on global "site-settings" requires role "cms:admin" - Field write restricted: No error -- restricted fields are silently stripped from the payload. The user sees the field as read-only in the admin UI.
- Scope access denied:
Access denied: you do not have access to scope "region-west" for type "region" - Policy helper failure:
Permission required: orders:manageorRole required: admin
All access denials in mutations cause the Convex transaction to roll back. No partial data is written.