Users — system / admin user management

The whole system-user module reduces to one idea:

A "system user" is a users row of a staff kind (Admin, SuperAdmin, Company, StoreAdmin, Staff) whose groupId points at an access group. Operators are almost never created through createUser directly — they are minted by the HR Employee create/invite flow, which writes the users row (kind=Staff, roles=[Staff], groupId) and the employees record together in one transaction. This module owns the user record, the per-tenant user cap, uniqueness validation, status (active/status), password reset (delegated to auth), and soft deletion. What a group can do is RBAC — owned entirely by permissions-access.

Source: BE src/modules/user (+ auth for password ops, hr/employee for onboarding) · Admin src/modules/user, src/modules/employees, pages src/pages/users/*, src/pages/admin-users.tsx, src/pages/maintenance/users.tsx

See _overview.md for the unified users/kind model and the full data dictionary.

1. Purpose & scope

Responsible for: the users collection record for operators; creating/inviting operators (via the employee wrapper); kinds & roles; assigning the access group; uniqueness enforcement; the per-tenant user limit; bulk import of parties; status (active/status); and account deletion.

Explicitly not responsible for:

  • What a group/role can accesspermissions-access.
  • Login, JWT issuance, OTP, password-reset internalsauth.
  • Customer/supplier party mastercrm/_overview (same collection, CRM kinds).
  • HR employment data (salary, leave groups, department) → HR employee.
  • Self profile & UI preferencesprofile-preferences.md.

2. Data model

The users collection (schema, fields, enums, indexes, soft-delete, tenant scoping) is documented once in _overview.md §2. The fields most relevant to a system user:

Field Type Required Notes
kind UserKindTypes yes Discriminator. Operators = Admin/SuperAdmin/Company/StoreAdmin/Staff. Default Customer.
roles UserRoleTypes[] no ([]) Coarse role gate; employee-created users get [Staff].
groupId ObjectId no The access group → RBAC (permissions-access).
username string no Login handle; defaults to email. Unique.
email string (sparse) no Unique.
phoneNumber string (sparse) no Stripped of special chars. Unique.
idNumber string no Unique.
name string no Unique on create only.
password string no bcrypt hash; falls back to DEFAULT_PASSWORD. Never returned.
active boolean no (true) Enabled flag (suspension = false).
status string no ("active") UserStatusTypes.Active / IN_ACTIVE.
activeBranchId / branchIds ObjectId / ObjectId[] no Current + allowed branches.
referralId string auto helper.nanoId() on create.
confirmEmailToken / forgetPasswordToken (+ expiries) string / number auto Set-password / reset tokens.

Relevant enums (full set in _overview.md §2.4):

export enum UserKindTypes { Admin, SuperAdmin, Company, StoreAdmin, Staff, Customer, Supplier }
export enum UserRoleTypes { Admin, SuperAdmin, Customer, StoreAdmin, Staff, Employee, SalesMan, Supplier }
export enum StaffRoleTypes { Admin = "Admin", SalesMan = "SalesMan", Staff = "Staff" }
export enum UserStatusTypes { Active = "active", IN_ACTIVE = "IN_ACTIVE" }

3. API surface

3.1 GraphQL — user/user.resolver.ts (@ApGqlAuthorize() on the resolver)

Operation Type Input Returns Notes / Audit
user(id) Query id: String User getProfile(id).
currentUser Query — (@GqlCurrentUser) User The signed-in user (whoami).
findOneUser(user) Query UserQueryInput User @ApGqlAuthorize({ ignoreCompanyQuery: true }) — searches across companies.
findUserById(id) Query id: String User findById.
userPage(page) Query UserPageInput UserPageResult Paginated, sortable, filter by kinds/keyword/accountId/etc.
createUser(user) Mutation CreateUserInput User AuditMeta(module:user, collection:users, CREATE).
updateUser(user) Mutation UpdateUserInput User Updates @GqlCurrentUser's own _id (self-update). AuditMeta UPDATE.
deleteAccount Mutation — (@GqlCurrentUser) Boolean Soft-mangle of the current user. AuditMeta DELETE.
idCheck(idInfo) Mutation UserIdCheckInput UserIdCheckResult KYC ID extraction — currently a stub returning null (body commented out).
importUsers(import) Mutation UserImportInput [UserImport] Parse XLSX → preview rows. AuditMeta CREATE.
confirmUsersImport(import) Mutation ConfirmUserImportInput Boolean Persist imported parties + opening-balance legs. AuditMeta CREATE.

updateUser is a self-update. The resolver calls this.userSvc.update(user._id, args) using the JWT's _id, not an id argument. There is no admin "update arbitrary user" GraphQL mutation in this module — operator edits go through the Employee updateEmployee mutation (which writes the linked user, see §6). deleteAccount is likewise self-only.

Input DTOs (user/user.dto.ts, mirrored in schema.gql):

input CreateUserInput {            # extends CommonUserInput + password
  ref, name!, phoneNumber, email, idNumber, address, country, city, postalCode,
  nickname, username, nationality, currencyId, parentId, password
}
input UpdateUserInput {            # = CommonUserInput (no password)
  ref, name!, phoneNumber, email, idNumber, address, country, city, postalCode,
  nickname, username, nationality, currencyId, parentId
}
input UserQueryInput { _id, name, email, phoneNumber, roles:[UserRoleTypes], kinds:[UserKindTypes], keyword, accountId }
input UserPageInput  { ...UserQueryInput, skip!, take!, sortBy, sortOrder }
input ConfirmUserImportInput { userKind!, users: [ConfirmImportUser!]! }
input ConfirmImportUser { name!, email, phoneNumber, debitAccountId!, creditAccountId!, currencyId!, balance! }

Note CreateUserInput/UpdateUserInput do not expose kind, roles, or groupId. Those are set server-side by the calling flow (employee create sets them; CRM services set them).

3.2 REST — user/user.controller.ts (/api/user)

Method Route Purpose
GET /api/user/download XLSX export of users (filtered by kind) with their GL-derived account balance. @ApiAuthorize().
GET /api/user/:userId/account/download XLSX of a single user's account statement (GL legs filtered by payeeId, with opening balance).

4. Business rules & validation

All in user/user.service.ts.

4.1 create(user)

  1. User cap. If tenantConfigSvc.getMaxUsers() > 0, count existing rows where kind ∈ {Admin, SuperAdmin, Company, StoreAdmin, Staff}; if >= maxUsers throw 406 "User limit of N reached for this tenant…". Customers/suppliers are exempt.
  2. ref = generateUserId() (date-stamped numeric, regenerated on same-day collision), referralId = nanoId(), username ||= email, phoneNumber = removeSpecialChar(phoneNumber).
  3. validateExist — see 4.3.
  4. Hash password || DEFAULT_PASSWORD; set confirmEmailToken = bcrypt(randomDigits) and confirmEmailExpiredIn = now + emailTokenTimeSpan days.
  5. Persist via userRepo.create.

4.2 update(id, user)

validateUpdateExist (4.3) → userRepo.update → return fresh findOne.

4.3 Uniqueness (validateExist create / validateUpdateExist update)

Field Create check Update check
email reject if any user has it → 406 emailExist reject if a different user has it → 406 updateEmailTaken
username reject if taken → 406 userNameExist — (not re-checked)
phoneNumber strip + reject if taken → 406 phoneNumberExist strip + reject if different user → 406 updatePhoneNumberTaken
idNumber reject if taken → 406 idNumberExist reject if different user → 406 updateIdNumberTaken
name reject if taken → 406 "Account <name> already exist" — (not re-checked)

Checks span all kinds (a customer name can block an operator name and vice-versa).

4.4 deleteAccount(userId) — soft mangle

Suffixes username, phoneNumber, email with _<newObjectId>_deleted (via update), freeing the unique values. Row is retained.

4.5 Import (import / confirmImport)

  • import(file): read XLSX, map columns (Name, Phone, Email, Dr/Cr Account by accountName, Currency by name, Balance) → [UserImport] preview. No persistence.
  • confirmImport({ userKind, users }) — in withRetryTransaction("import_user"):
    1. per row validateExist, set keywords from name.
    2. look up the seeded access group findOne({ group: userKind }) → throw if missing.
    3. super.create({ ...row, kind: userKind, password: hash(DEFAULT_PASSWORD), groupId, accountId }) (accountId = debit for Customer, credit otherwise).
    4. post two opening-balance GL legs (Dr + Cr, direction by kind) with a shared relationId.
    5. accountSvc.validateBalanced() — abort the whole txn if unbalanced.

4.6 State (status) machine

No state-machine mutation. Two independent flags toggled via updateUser: active: boolean (default true) and status: string (ActiveIN_ACTIVE). Login enforcement of these is in auth.

4.7 Side effects & transactionality

  • create writes one users row (+ employee row when called from EmployeeService.create, which wraps the whole thing in a txn).
  • confirmImport writes users and GL legs atomically and asserts ledger balance.
  • All operator-creating paths emit an audit snapshot via @AuditMeta.

5. Permissions

Admin module user-maintenance (zerp-admin/src/constants/UserAccess.tsUSER_ACCESS.USER_MAINTENANCE), actions: view, create-user, update-user, delete-user, import-users, view-user-access, view-user-details, view-employees, create-employee, view-department, create-department.

  • Pages guard via ApGuardBuilder.haveAccess(MODULE, ACTION, '/') in getServerSideProps.
  • admin-users.tsx is additionally hard-gated to session.user.kind === SuperAdmin.
  • BE mutations: @ApGqlAuthorize() + @AuditMeta.
  • The group→permission mechanism itself: permissions-access.

6. Flows

6.1 Create an operator (the real path — via Employee)

1. Admin /maintenance/users → "Create Employee"  (employees module, components/create.tsx)
   Formik: user.name*, user.email*, user.phoneNumber, user.address, user.password (new only),
           group* (access group select), store* (branch select)
2. createEmployee($employee: CreateEmployeeInput!)   (employees/gql)
3. EmployeeService.create()  [withRetryTransaction "create_employee"]
   a. if a users row already has user.email → invite() (reuse), else continue
   b. UserService.validateExist(user)               (uniqueness)
   c. UserService.create({ ...user, kind:Staff, roles:[Staff], groupId })   ← cap + ref + hash
   d. employeeRepo.create({ userId, groupId, branchId })
4. emit "employee.created"; operator can log in.

Unhappy paths: user cap reached → 406; duplicate email/phone/id/name → 406; missing group/store → Formik validation blocks submit.

6.2 Invite an existing user as operator

1. Admin → "Invite Employee" (employees/components/invite.tsx): search by email
2. findOneUser({ email })  → if match, render CreateEmployee with isInvite=true (user fields disabled)
3. inviteEmployee → EmployeeService.invite(): validateExist(employee) → employeeRepo.create({ userId, groupId, branchId })
   (no new users row; reuses the matched one)

6.3 Edit / reassign an operator's group

updateEmployee(id, employee)  → EmployeeService.update():
   Promise.all([ employeeRepo.update(id, employee),
                 UserService.update(emp.userId, { ...employee.user, groupId }) ])

The new groupId is written to both the employee and the user — re-pointing RBAC.

6.4 Suspend / reactivate

Set active=false (and/or status=IN_ACTIVE) via an update; reverse to reactivate. No dedicated mutation; enforcement is in auth.

6.5 Password reset (auth-owned, on the same row)

forgotPassword(email) / phoneForgotPassword(phone)  → set forgetPasswordToken + expiry (+ WhatsApp on phone)
resetPassword(options) → changePasswordByToken(token, newPw)  (validate token+expiry, rehash, clear token)
changePassword(old,new) (signed-in)  → compareData(old) then rehash
changeUserPassword(new) (admin-forced)  → rehash, no old-pw check

Returns generic success on forgotPassword (no account-existence leak). See auth.

6.6 Bulk import parties

See §4.5 and crm/_overview §5.4. Pages /users/import and /users/confirm-import?kind=… (guard: import-users).

7. Admin UI

  • Pages
    • /maintenance/users<EmployeesPage /> — the operator list + create/invite (guard view-employees). This is the primary operator-management surface.
    • /admin-users → employee table, SuperAdmin-only (columns: Name, Email, Phone, Kind, Company, Store, Group, CreatedAt).
    • /users/import, /users/confirm-import → CRM party import (guard import-users).
  • User module (src/modules/user) — mostly read/import: gql exposes USER_PAGE (userPage), FIND_ONE (findOneUser), IMPORT_USER (importUsers), CONFIRM_USER_IMPORT (confirmUsersImport). context.tsx methods: fetchUserPage, findOneUser, importUsers, confirmUsersImport. columns.tsx defines the Name/Account Type/Phone/Email/Currency/Balance grid; layout.tsx is the user-detail chrome.
  • Employee module (src/modules/employees) — the create/invite forms.
    • components/create.tsx: Formik user.name* / user.email* / user.phoneNumber / user.address / user.password (new only); group* = <ApSelectInput name="group" options={accessGroups} labelKey="group" valueKey="_id"> (access-group assignment); store* = branch select (option {name:'All', _id:'*'} + stores). Yup requires user.name, user.email, group._id, store._id. On invite, user fields are disabled.
    • context.tsx: createEmployee / inviteEmployee / updateEmployee / deleteEmployee / fetchEmployeePage (+ import). Each toasts and refreshes the page after the mutation.
  • Access-group select is the UI bridge to RBAC; the group's permissions are administered separately (see permissions-access).

8. Dependencies & integrations

  • hr/employee — the onboarding wrapper; EmployeeService injects UserService and creates the kind=Staff user.
  • permission/group (AccessGroupService)groupId target; confirmImport resolves the seeded group by userKind. Seeded groups: Admin, Salesman, Marketing, Customer, Supplier.
  • finance/account + finance/transaction — import opening-balance legs; controller balance export.
  • master — currency lookup (currencyId).
  • tenant-configgetMaxUsers() cap.
  • auth — login + all password operations; idCheck (KYC) is stubbed.
  • upload — XLSX import file handling.

9. Gotchas & project-specific rules

  • No admin "edit any user" GraphQL mutation here. updateUser/deleteAccount act on the current user only. Operator edits go through updateEmployee. Don't look for updateUser($id, …) — it isn't there.
  • createUser exists but isn't the operator path. The admin user module mainly does page/find/ import; operators are created via Employee create/invite (which sets kind/roles/groupId).
  • CreateUserInput can't set kind/roles/groupId. A raw createUser therefore lands a kind=Customer (the schema default) with no group — almost never what you want for an operator.
  • User cap = staff kinds only. Customers/suppliers don't count toward maxUsers.
  • idCheck is a stub — returns null; the KYC extraction body is commented out.
  • name & username uniqueness aren't re-checked on update — only email/phone/idNumber are.
  • Delete is a mangle (deleteAccount), not a hard removal; combine with mongoose-delete for true soft delete. CRM parties additionally require zero balance (canDelete).
  • Admin updateUser sends account: arg but BE binds @Args("user") — cosmetic alias, see _overview §7.