how-to
Admin accounts, roles & granular access
Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01
Admin accounts, roles & granular access
Multi-admin support. The super admin (from secrets) can create normal admins and grant each a subset of granular permissions. Normal admins cannot create or manage other admins.
Concepts
- Super admin — the
ADMIN_USERNAME/ADMIN_PASSWORDBasic-auth account. Implicitly holds every permission (includingadmins:manage). Immutable; cannot be created, suspended, or edited through the API. Optionally also seedable as a JWT login (below). - Normal admin — a
Userwithrole: admin, anadminStatus, and apermissions[]subset. Logs in viaadminLogin(JWT). Can only access the areas it's been granted. - Permissions — see
src/common/auth/admin-permissions.ts:payments:read|write,ledger:read|write,trueup:read|approve,virtual-cards:manage,users:read|write,subscriptions:manage,notifications:send,bundle-runs:read|write,admins:manage(super admin only).
Auth model
Every admin endpoint is protected by AdminAuthGuard, which accepts either:
- JWT —
Authorization: Bearer <token>(or a raw token). Token fromadminLogin/setAdminPassword. The guard re-loads the user each request and checks role +adminStatus: active+ the required permission(s). - Basic —
Authorization: Basic base64(ADMIN_USERNAME:ADMIN_PASSWORD)→ super admin.
@RequirePermissions(...) on a handler/class lists the permission(s) required (AND). The
super admin bypasses permission checks.
GraphQL API
Public (no auth)
mutation AdminLogin($input: AdminLoginInput!) { # email + password -> JWT
adminLogin(input: $input) { token admin { id email role permissions } }
}
mutation SetAdminPassword($input: SetAdminPasswordInput!) { # invite accept -> JWT
setAdminPassword(input: $input) { token admin { adminStatus } }
}
Authenticated admin (any)
query { adminMe { id email role adminStatus permissions } } # for nav gating
Super admin only (require admins:manage)
mutation CreateAdmin($input: CreateAdminInput!) { # input: email, firstName?, lastName?, permissions[]
createAdmin(input: $input) {
admin { id email adminStatus permissions }
inviteToken inviteLink inviteEmailSent # deliver link manually if email didn't send
}
}
query { listAdmins { id email role adminStatus permissions lastLogin createdAt } }
mutation UpdateAdminPermissions($input: UpdateAdminPermissionsInput!) { # adminId, permissions[]
updateAdminPermissions(input: $input) { id permissions }
}
mutation SuspendAdmin($adminId: String!) { suspendAdmin(adminId: $adminId) { id adminStatus } }
mutation ReactivateAdmin($adminId: String!) { reactivateAdmin(adminId: $adminId) { id adminStatus } }
mutation ResendAdminInvite($adminId: String!) {
resendAdminInvite(adminId: $adminId) { inviteToken inviteLink inviteEmailSent }
}
createAdmin/updateAdminPermissions reject admins:manage in the permission list
(400) and reject editing/suspending a super admin (404). Creating with an email that
already belongs to any user returns 409.
Lifecycle
- Super admin →
createAdmin(permissions subset). Admin isinvited; an email with a/set-password?email=&token=link is sent (link also returned in the response). - New admin opens the link →
setAdminPassword→active, receives a JWT (auto-login). - Thereafter
adminLogin. JWTs expire in 12h. suspendAdminblocks login immediately (guard re-checks status each request).
All management actions are recorded in the adminauditlogs collection.
Frontend (bundul-admin) work
- Login screen →
adminLogin; store JWT; sendAuthorization: Bearer <jwt>on every admin request (replaces the Basic-auth prompt for normal admins). - Set-password screen at
/set-passwordreadingemail/tokenfrom the query →setAdminPassword→ store returned JWT. - Admins management (visible only when
adminMe.permissionsincludesadmins:manage): list, create (with a permission picker over the grantable permissions), edit permissions, suspend/reactivate, resend invite. - Permission-aware nav: hide/disable each area unless
adminMe.permissionsincludes the matching permission. Handle 401 (re-login) and 403 (insufficient permission) responses. - Super admin: seeded from
ADMIN_USERNAME/ADMIN_PASSWORDwhen the username is an email address. (SUPER_ADMIN_EMAIL/SUPER_ADMIN_PASSWORDare not read anywhere in the code — a stale comment inadmin-auth.guard.tsstill mentions them.) The super admin logs in throughadminLoginlike everyone else. Otherwise it continues to use the Basic-auth credentials (the panel can keep sending Basic for super admin).
Environment variables
| Var | Required | Purpose |
|---|---|---|
ADMIN_USERNAME / ADMIN_PASSWORD |
yes (existing) | Super admin Basic-auth credentials. |
ADMIN_PANEL_URL |
recommended | Base URL for invite links (default https://bundul-admin.onrender.com). |
ADMIN_USERNAME / ADMIN_PASSWORD |
required | Seeds the super_admin user when ADMIN_USERNAME is an email address, so the super admin can log in via adminLogin (JWT). |
Rollout
- Deploy — Basic auth keeps working, so the existing panel/scripts are unaffected.
- Make sure
ADMIN_USERNAMEis an email address — that is what enables super admin JWT login. - Ship the bundul-admin changes; migrate normal admins to JWT login.
- Legacy
AdminBasicAuthGuard/AdminGqlAuthGuardare now unused by app code (kept for back-compat); remove once nothing relies on Basic auth.