Users & Roles
User management is controlled by role-based permissions. Super admins have full access, while other users need explicit custom role permissions (e.g. a "Prospect Manager" role) to manage users from Admin > Users.
User Roles
The platform has four built-in roles. Their behavior is fixed by code, so the role editor for these is locked down — open them in Admin > Roles and you'll see an info banner instead of a form (Visitor is the one exception; see below). Extend a user's access by assigning custom roles on top of the built-in:
| Role | Description | Manager Access | Editor |
|---|---|---|---|
visitor | Not signed in. Can only view published content. Default permissions are tunable (e.g. allow visitors to submit contact forms or subscribe). | None | Permissions field editable; other fields locked. |
user | Signed in. Can manage their own profile, view their own orders, and view their own carts. Self-only scoping is enforced in code. | None | Banner only (locked). Extend via custom roles. |
admin | Grants access to the admin dashboard for data management, plus visitor + user-level data access. Table-level permissions (CRUD on Articles, Products, etc.) come from custom roles paired alongside Admin. | Dashboard entry only — table CRUD requires custom roles | Banner only (locked). Extend via custom roles. |
super | Full access to all tables and actions. Hardcoded — cannot be revoked or narrowed via the UI. | Full | Banner only (locked). |
Permissions
Base permissions by role. Admins can gain additional permissions through custom roles assigned by a Super admin:
| Action | Visitor | User | Admin (base) | Super |
|---|---|---|---|---|
| Read public records | Yes | Yes | Yes | Yes |
| Read own user data | - | Yes | Yes | Yes |
| Update own profile | - | Yes | Yes | Yes |
| CRUD data tables | - | - | Via custom roles | Yes |
| Manage users | - | - | Via custom roles | Yes |
| Assign roles | - | - | Via custom roles (cannot assign super/admin) | Yes |
| Delete records | - | - | - | Yes |
| Edit site config | - | - | - | Yes |
| Create / edit webhooks and API keys | - | - | - (read only via custom roles) | Yes |
| Change Visitor role permissions | - | - | - | Yes |
| Delete audit records | - | - | - | No (append-only) |
admin label is a dashboard key, not a permission — and that includes the admin "convenience" actions outside the data tables. Looking up another customer's orders requires an ORDER read grant; resending an order email requires ORDER update; resending a welcome email, re-issuing an onboarding token, or resetting a user's 2FA requires a cross-user USER update grant; and running low-stock checks or inventory alerts requires PRODUCT update. An admin without a custom role granting the matching table permission gets a 403.Custom Roles
Custom roles grant specific table-level permissions to admins. A Super admin creates custom roles from Admin > Roles and assigns them to users. For example:
- Blogger - Manages articles and assets. Reads categories and subscribers (to see who'd get notified on publish). Gets BCC'd on every article create/update.
- Shop Manager - Runs the storefront: products, categories, coupons, and the order admin (read + fulfillment updates — refunds remain super-only).
- Comms Director - Owns inbound and outbound communication: subscribers, contacts, homepage slides, and feedback. Email notifications on every change.
- MOAS Buyer - Procurement role that opts into pay-by-invoice via the
moas.canExpenseflag. Pairs with a MOAS Manager who approves the resulting expense orders. - MOAS Manager - Approves expense orders placed by MOAS Buyers.
moas.canApprove+approverFor: ["moas-buyer"].
super or admin roles, and no user can modify their own roles. User deletion remains super-only. A non-super who is allowed to create or edit custom roles also can't use one to escalate: a custom role may not grant any table/action the role's author doesn't already hold themselves (Super admins define the ceiling and are exempt).The same ceiling applies when a role is assigned. A non-super user manager can only give someone a custom role whose access they already hold themselves: every table and action the role grants must be one they have, and if their own access to a table is limited by a record filter (for example "only my own articles"), the role must carry that same filter. Otherwise the save is refused with the reason, whether it's a single edit, a bulk edit or import, or a new user. Taking a role away is always allowed, and so is keeping a role the user already has. The role picker on the user form greys out roles you can't assign. Super admins can assign any role except root. The same rule caps API keys: a key that adds roles to a user through the external API can only add roles within the key's own permissions. Hidden fields aren't part of this check (or of the authoring one), so a role you assign may show fields that are hidden from you.
The same ceiling caps identity writes. A non-super user manager can set the password, change the email, disable two-factor or re-issue the onboarding link only for an account whose live custom roles are all within their own ceiling. Taking over a colleague who already holds a broader role would otherwise be an easier route to that role than assigning it. The built-in admin label counts too: a manager who is not an admin cannot write the password, email, two-factor settings or onboarding link of an admin account, since they could never assign that label. Super and Root accounts are protected outright, whatever the manager holds; ordinary profile edits on the same account are unaffected.
root, held only by your platform developer. It carries everything Super does, plus the developer-managed site settings — turning paid modules on, changing the no-reply / developer email addresses, and the license tier and baseline rate. The root role can't be granted or revoked from the admin panel at all — not even by a root user; it is assigned only by the platform's own setup tooling — and for the same reason your developer's account itself can't be deleted here.Per-user record filters
A custom role's table permission can include record filters that scope which rows a signed-in user can see or modify. Filter values support ${user.<field>} placeholders that resolve at read time to the actual signed-in user's data — so the same role definition produces different per-user results.
Supported placeholders include any field on the user record:
${user.id}- The user's database ID${user.email}- Their email address${user.handle}- Their public handle (e.g.jdoe)${user.name}- Their display name- Any other top-level field on the User record (e.g.
${user.region}if you've added a custom region field)
Concrete patterns this unlocks:
| Use case | Custom role | Table | Filter |
|---|---|---|---|
| Author-only article editing | Contributor | article | authoredBy equals ${user.handle} |
| Sales rep order ownership | Sales Rep | order | assignedRepId equals ${user.id} |
| Regional content scoping | Editor (West Coast) | article | region equals ${user.region} |
| Vendor portal (multi-supplier) | Vendor | product | supplierId equals ${user.supplierId} |
| Helpdesk ticket assignment | Support Rep | contact | assignedTo equals ${user.id} |
purchaserUserId equals ${user.id}). It's available to any custom role on any table — pair the right filter with the table you want to scope. Identity placeholders (${user.id}, ${user.email}, ${user.handle}) are always exact-match: a contains / startsWith / endsWith on one is narrowed to equals at read time, and ${user.email} only resolves once the address is confirmed — an unconfirmed email is not proof of identity.Fail-safe behavior: if a placeholder can't be resolved (e.g. the field doesn't exist on the user record, or the signed-in user lacks that field), the filter is left as the literal placeholder string. The downstream query then matches nothing — so the role never leaks unscoped data when a placeholder is misconfigured. Check the server logs for [role-filter] Unresolved placeholder warnings if a role isn't returning the expected rows.
Creating Users & Onboarding
Users can be created in two ways: self-registration (if sign-up is enabled in Site Config) or admin onboarding (an authorized admin creates the account manually).
Self-Registration
When a visitor signs up, they provide their name, email, and password. The account is created with the user role by default and an active status. Sign-up can be disabled entirely from Site Config > Security > Allow sign up.
Admin Onboarding
Users with the appropriate custom role permissions (e.g. "Prospect Manager") or Super admins can create accounts on behalf of others from the Users table. This triggers the welcome email flow:
- Admin enters the new user's name and email.
- The system creates the account with
pendingstatus and generates a secure onboarding token. - A welcome email is sent with the subject "Welcome to [Site Name] - Set up your account".
- The email contains a link to the onboarding page where the user sets their password.
- The onboarding token expires after 7 days. If it expires, an admin can issue a new one — re-issuing a token counts as user management, so it requires the same USER UPDATE grant (a baseline admin without one gets a 403).
- Once the user completes setup, their status changes to
activeand they can sign in normally.
User Status
Every user account has a status that controls their ability to sign in and access the platform:
| Status | Can Sign In | Description |
|---|---|---|
active | Yes | Normal operating state. Full access based on assigned roles. |
pending | No | Account created via admin onboarding but user has not completed setup. |
suspended | No | Temporarily blocked by an admin. User cannot sign in until reinstated. |
inactive | No | Deactivated account. User cannot sign in until reactivated. |
Revoking Access
To revoke a user's access, a Super admin changes their status to suspended or inactive from the Users table (user status is a super-only field). This has two effects:
- Immediate prevention of new sign-ins - The user cannot authenticate.
- Active session termination - Every session the account holds is deleted at once, and any session that still reaches the app for a suspended, inactive or deletion-requested account is refused on its next request (the status is re-read from the database on every session read), so a session can't be kept alive or re-minted around the suspension.
To reinstate access, change the status back to active. The user can then sign in again with their existing credentials.
Sessions
The platform uses JWT-based sessions. When a user signs in, a JSON Web Token is created containing their identity, roles, and status. Key behaviors:
- Session duration - Sessions expire after 30 minutes of inactivity. Active usage refreshes the token automatically.
- Token refresh - On each refresh, the system re-fetches the user's current data from the database, ensuring role changes and status updates take effect without requiring a manual sign-out.
- Session payload - Includes user ID, name, email, roles, status, theme preference, avatar, and 2FA status.
- Secure cookies - Session tokens are stored in HTTP-only cookies with a configurable prefix.
Managing Users
From the Users table, authorized users can:
- View all users - See account details, roles, status, and activity. (Requires custom role with USER READ_MANY permission)
- Create users - Onboard new users with name, email, and role assignment. (Requires custom role with USER CREATE permission)
- Assign roles - Edit role assignments on other users. (Requires custom role with USER UPDATE permission.) Non-super users cannot assign the
superoradminroles, can only add custom roles within their own access (see Custom Roles above), and nobody can modify their own roles. - Update profiles - Edit user details like name, email, or account balance. (Requires custom role with USER UPDATE permission)
- Change status - Suspend, deactivate, or reactivate accounts. (Super only)
- Delete users - Remove user accounts. (Super only)
User Page
Every signed-in user has access to their user page at Dashboard > User. The page is split into two sections — Account (private; only the user and admins see it) and Profile (opt-in public surface).
Account section
- Full name - Legal/full name. Used for invoices and admin records — not shown publicly.
- Email - Sign-in email. Email changes may require re-authentication.
- Phone - Optional contact number.
- Shipping & billing addresses - Pre-filled during checkout.
- Theme preference - Light, Dark, or System (follows the device's
prefers-color-scheme). Rendered as a 3-segment switcher next to the page heading. New users default to System.
Profile section (public)
These fields appear at /user/<handle> when the Publish profile toggle is on. Fields are marked with a Globe badge in the form — solid when published, dimmed when hidden.
- Handle - The URL slug for the public profile. Optional — if left blank, the server defaults it to the user's internal ID. The form offers a Sparkles button (slugify from full name) and a Dice button (roll a silly random name).
- Publish profile - Toggle that controls whether the profile is live at the handle URL.
- Avatar - Upload a profile image (max 1 MB, 1:1 aspect ratio).
- Display name (
publicName) - The name shown publicly. Falls back to@handlewhen blank. - Location (
publicLocation) - Optional public location string. - Bio (
publicBio) - Optional public bio. - Socials (
publicSocials) - List of public social media URLs.
readUserProfile() uses an explicit Prisma select that names only the public* columns + avatar + handle. Adding a column to the User table does NOT expose it publicly until it's added to that select list.System fields (admin-only)
accountBalance, roles, status, and confirmed are filtered out of the self-edit surface entirely — even supers see them only via the admin DataManager at /admin/data/user. Password reset and 2FA management live on the dedicated Security tab.
Two-Factor Authentication
Users can enable 2FA from the Security tab using a TOTP authenticator app (Google Authenticator, Authy, etc.). Once enabled, sign-in requires both the password and a time-based one-time code.
- Setup - User scans a QR code with their authenticator app and confirms with a verification code.
- Backup codes - Generated during setup for account recovery if the authenticator is lost.
- Admin visibility - Users with appropriate permissions can see whether a user has 2FA enabled but cannot configure it on their behalf.
- Admin reset - If a user loses access to their authenticator and backup codes, an admin with a user-management grant (Super, or a custom role granting USER UPDATE) can reset their 2FA settings, allowing the user to set it up again. The baseline admin label alone is denied, and only a Super can reset another Super's 2FA.
- Required for admins - When the site policy requires 2FA for the admin (or super) tier, an admin who hasn't enrolled by the end of the grace period is sent to the Security tab instead of the admin dashboard. The same rule applies to the admin APIs, to creating, editing, deleting, importing or exporting records, and to operator actions such as force sign-out, resetting someone's 2FA, marking an invoice paid, syncing orders with Stripe or deciding an approval: those requests are refused with
TWO_FACTOR_REQUIREDuntil 2FA is set up. Editing your own profile, submitting the site's public forms, signing out and setting up 2FA keep working. Viewing data, uploading files and record edit locks are not affected, and customer-side approvers who aren't admins are never gated.
Roles Table
The Roles table (Admin > Roles) defines the available roles in the system. The four built-in roles (visitor, user, admin, super) are seeded once and locked. Super admins manage the Roles table and create custom roles for everything else.
Each role has a name, description, optional notification email, and a set of table-level permissions (with optional record filters and hidden fields). Roles are assigned to users as an array, so a user can hold multiple roles. Permissions from all assigned roles are unioned — the combined set of granted actions determines effective access.
Notifications email out the full record, so they are held to a higher bar than other role edits. Only a Super admin can set or change a role's notification email or add email notifications to its permissions; otherwise anyone allowed to edit roles could have another table's records sent to an inbox they control. A role manager can still remove notifications. Every change to them is recorded in the audit log.
A hidden field is hidden in both directions: a role that can't see a field can't set it either. The server drops a hidden field from every create, update, CSV import, and bulk update the role makes, even when a request is hand-built outside the Data Manager, and the rest of the change still saves. Super admins are never redacted. Hiding applies to top-level fields only, not to keys nested inside JSON fields, and the record ID and the automatic "updated by" stamp are always kept.
Built-in role lockdown
Opening a built-in role in the editor renders an info banner instead of a form. This is intentional — built-in role behavior is enforced in code, so editing the stored permissions, slug, name, or status would have no effect (or worse, cause a silent break). The exception is visitor, where the permissions field stays editable so a Super can tune what non-authenticated users are allowed to do (e.g. submit contact forms, subscribe). Only a Super can change it: Visitor permissions apply to everyone who isn't signed in and every signed-in account inherits them, so a delegated role manager's own access is not a safe limit for them.
Built-in roles also cannot be deleted from the Roles table — the API rejects any deletion attempt on a record where static is true.
Custom roles like "Blogger", "Shop Manager", or "Comms Director" allow fine-grained delegation without granting full admin or super access. They support all the same per-user record filters described above.
Project GitHub authorization
GitHub project onboarding uses a separate privilege boundary: only a Super with the required verification and observed GitHub administrator authority can approve enrollment or issue an agent request grant. Custom Project CRUD permissions and the Agency project-notes API key do not grant GitHub operations. A separate mt_onboard_ credential can request jobs for one explicitly approved project; it cannot execute provisioning or mint release tokens. Manage this at Project GitHub onboarding.
B2B role permissions: Can expense / Can approve / Approver for
In addition to the table-level permission array, each role has three top-level fields that power the B2B procurement features:
- Can expense orders — users in this role see a "Pay by Invoice" payment option at checkout (skips Stripe; invoice is emailed). Also requires Site Config > Checkout > Allow Pay-by-Invoice to be enabled at the site level. (The field is named "Can expense orders" internally — Pay by Invoice and Expense Order refer to the same flow.)
- Can approve orders — users in this role can see
/dashboard/approvalsand act on its queue. Doesn't by itself say WHICH orders they approve — pair it with "Approver for" below. - Approver for — the list of buyer-role slugs this role approves expense orders for. When a buyer with role X places an expense order, every role whose "Approver for" includes X gets the approval-request email. Notification target is the role's
emailfield (shared inbox) if set, else all individual users holding the role. Example: amanagerrole with "Approver for" =['junior-buyer']approves every junior buyer's expense order.
All three respect the built-in role lockdown — only custom roles can toggle them. SSO providers that map IdP group claims to role slugs will auto-grant these capabilities through normal role assignment, which means the entire approval topology is configured once on the Role records and SSO-assigned users inherit automatically.