Bundul
Internal
Browse docs
Waiting for review

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_PASSWORD Basic-auth account. Implicitly holds every permission (including admins:manage). Immutable; cannot be created, suspended, or edited through the API. Optionally also seedable as a JWT login (below).
  • Normal admin — a User with role: admin, an adminStatus, and a permissions[] subset. Logs in via adminLogin (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:

  1. JWTAuthorization: Bearer <token> (or a raw token). Token from adminLogin / setAdminPassword. The guard re-loads the user each request and checks role + adminStatus: active + the required permission(s).
  2. BasicAuthorization: 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

  1. Super admin → createAdmin (permissions subset). Admin is invited; an email with a /set-password?email=&token= link is sent (link also returned in the response).
  2. New admin opens the link → setAdminPasswordactive, receives a JWT (auto-login).
  3. Thereafter adminLogin. JWTs expire in 12h.
  4. suspendAdmin blocks login immediately (guard re-checks status each request).

All management actions are recorded in the adminauditlogs collection.

Frontend (bundul-admin) work

  • Login screenadminLogin; store JWT; send Authorization: Bearer <jwt> on every admin request (replaces the Basic-auth prompt for normal admins).
  • Set-password screen at /set-password reading email/token from the query → setAdminPassword → store returned JWT.
  • Admins management (visible only when adminMe.permissions includes admins: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.permissions includes the matching permission. Handle 401 (re-login) and 403 (insufficient permission) responses.
  • Super admin: seeded from ADMIN_USERNAME/ADMIN_PASSWORD when the username is an email address. (SUPER_ADMIN_EMAIL/SUPER_ADMIN_PASSWORD are not read anywhere in the code — a stale comment in admin-auth.guard.ts still mentions them.) The super admin logs in through adminLogin like 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

  1. Deploy — Basic auth keeps working, so the existing panel/scripts are unaffected.
  2. Make sure ADMIN_USERNAME is an email address — that is what enables super admin JWT login.
  3. Ship the bundul-admin changes; migrate normal admins to JWT login.
  4. Legacy AdminBasicAuthGuard / AdminGqlAuthGuard are now unused by app code (kept for back-compat); remove once nothing relies on Basic auth.