User Roles & Permissions (F5.5)
Status: 🟢 COMPLETED Feature: ADMIN/USER/RESELLER roles, role-based route middleware, reseller sub-accounts, per-user resource quotas.
What It Does
F5.5 adds a third role (RESELLER) on top of the existing ADMIN/USER roles, and closes a gap where no route in the API actually checked a caller's role — every route only checked that a session existed (requireAuth). Concretely:
ADMIN— everything, including a new/userspage to view every account, change any user's role, edit quotas, and delete users. Firewall and Backups (previously reachable by any authenticated user) are now ADMIN-only.USER— unchanged: manages their own projects, domains, databases, and mailboxes, each capped by their own resource quotas.RESELLER— a new tier that creates sub-accounts (plainUSERrows withresellerIdpointing at the reseller) and sees only themselves plus their own sub-accounts on/users. Capped by their ownmaxSubAccountsquota.- Resource quotas — every user has nullable
maxProjects/maxDomains/maxDatabases/maxMailboxes/maxSubAccountsfields (null= unlimited), enforced at creation time with aQUOTA_EXCEEDED(403) error.
Architecture
Dashboard (/users page, Sidebar role-gated nav)
│ REST: GET/POST /api/users, PATCH /api/users/:id/{role,quotas}, DELETE /api/users/:id
▼
apps/api/src/modules/users/{routes,service,schema}.ts
│ requireRole("ADMIN") / requireRole("ADMIN","RESELLER") preHandlers
▼
apps/api/src/plugins/auth.ts — requireRole(...roles)
│ Looks up the caller's current role from Postgres on every call (not
│ cached in the session), so a role change takes effect immediately.
▼
apps/api/src/utils/quota.ts — assertUnderQuota(prisma, userId, resource, makeError)
│ Called from projects/domains/databases/mailboxes/users `create()`
▼
Prisma `User` model — role, resellerId (self-relation), max* quota fieldsKey Files
| File | Purpose |
|---|---|
apps/api/prisma/schema.prisma | Role enum (+RESELLER), User.resellerId self-relation, max* quota fields |
apps/api/src/plugins/auth.ts | requireRole(...roles) preHandler factory |
apps/api/src/utils/quota.ts | assertUnderQuota — shared count-based quota check |
apps/api/src/modules/users/{routes,service,schema}.ts | User listing/role/quota/sub-account management |
apps/api/src/modules/firewall/routes.ts, apps/api/src/modules/backups/routes.ts | Gated from requireAuth to requireRole("ADMIN") |
packages/shared/src/schemas/users.ts | Zod schemas shared between API and dashboard |
apps/dashboard/src/hooks/useUsers.ts | React Query hooks (list, create sub-account, update role/quotas, delete) |
apps/dashboard/src/components/users/ | All UI components |
apps/dashboard/src/app/(panel)/users/page.tsx | Next.js route |
apps/dashboard/src/components/layout/Sidebar.tsx | Nav items hidden per-role via roles on each item |
Role Middleware
app.requireRole(...roles: Role[]) (in apps/api/src/plugins/auth.ts, alongside the existing requireAuth) returns a preHandler that:
- 401s if there's no session (same as
requireAuth). - Looks up the caller's
rolefresh from Postgres — deliberately not cached in the Redis session, so an admin changing someone's role takes effect on their very next request instead of requiring them to log out and back in. - 403s with
{ code: "FORBIDDEN_ROLE" }if the role isn't in the allowed set. - Attaches
request.userRoleso the route handler doesn't need a second lookup.
Sub-accounts & Quotas
- A sub-account is just a normal
Userrow withresellerIdset to its owning reseller (Userself-relationreseller/subAccountsinschema.prisma) — no separate join table. UserService.list()scopes by requester:ADMINgets every user;RESELLERgetsWHERE id = self OR resellerId = self.assertUnderQuota(prisma, userId, resource, makeError)looks up the matchingmax*field (null= unlimited), counts existing non-deleted rows for that resource, and throws viamakeError— each module wraps it in its own existing error class (ProjectError,DomainError,DatabaseError,MailboxError,UserError) so the existing per-module error handler inroutes.tspicks it up unchanged.- Quota checks run at the very top of
create()inprojects/service.ts,domains/service.ts,databases/service.ts,mailboxes/service.ts, andusers/service.ts(createSubAccount).
How to Test
pnpm db:seedfromapps/api/seedsadmin@vexlyx.local,reseller@vexlyx.local(maxSubAccounts: 5), andsub-account@vexlyx.local(maxProjects: 3, owned by the reseller) — all with passwordadmin123.- Log in as admin →
/usersshows every account; edit a user's role/quotas via the pencil icon. - Log in as reseller →
/usersshows only the reseller + its sub-account; "New Sub-account" creates additionalUSERrows untilmaxSubAccountsis hit (expect aQUOTA_EXCEEDEDtoast). - Log in as the sub-account (plain
USER) →/users,/firewall,/backupsnav items are hidden; directGET /api/users,/api/firewall,/api/backupsall return 403. - As the sub-account, create projects past
maxProjects(3) → the 4th attempt returns 403QUOTA_EXCEEDED.
F5.8 — Direct User Provisioning, Registration Lockdown & Logout
POST /api/users(apps/api/src/modules/users/routes.ts) now acceptsrequireRole("ADMIN", "RESELLER"), not justRESELLER.UserService.createSubAccount(apps/api/src/modules/users/service.ts) branches onrequester.role: an ADMIN can set anyrole(via the now-optionalrolefield onCreateSubAccountSchema,packages/shared/src/schemas/users.ts) and isn't quota-checked; a RESELLER is still forced torole: "USER",resellerId: requester.id, and quota-checked againstmaxSubAccounts.CreateSubAccountDialog.tsxtakes anisAdminprop — when true it shows a roleSelectand the button reads "New User"; when false (reseller) it behaves exactly as before.UsersPage.tsxrenders it for both roles now.ALLOW_REGISTRATION(apps/api/src/config/env.ts) now defaults tofalse— a fresh install is a closed panel; the first admin (fromcreate-admin.ts/seed.ts) provisions everyone else via/users. A new publicGET /api/auth/configroute exposes the current value so the dashboard doesn't have to guess;useAuthConfig.tsfetches it once.LoginForm.tsxhides the "Create one" link when disabled, andRegisterForm.tsxshows an "ask your administrator" message in place of the form.- Logout was previously dead code (
useAuth().logout()existed but nothing called it) —Header.tsxnow has an account dropdown (shadcndropdown-menu) showing the user's name/email with a "Log out" item.
How to Extend
- More admin-gated modules: change the route's
preHandlerfromapp.requireAuthtoapp.requireRole("ADMIN")(or addRESELLERwhere a reseller should also reach it) — same one-line change made tofirewall/routes.tsandbackups/routes.ts. - More quota'd resources: add a
max<Resource>column toUser, a case inQUOTA_FIELD/countExistinginapps/api/src/utils/quota.ts, and oneassertUnderQuota(...)call at the top of that resource'screate(). - Reseller self-service quota requests: out of scope here — quotas are currently ADMIN-set only via
PATCH /api/users/:id/quotas.