Users — system / admin user management
The whole system-user module reduces to one idea:
A "system user" is a
usersrow of a staffkind(Admin,SuperAdmin,Company,StoreAdmin,Staff) whosegroupIdpoints at an access group. Operators are almost never created throughcreateUserdirectly — they are minted by the HR Employee create/invite flow, which writes theusersrow (kind=Staff,roles=[Staff],groupId) and theemployeesrecord 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 access → permissions-access.
- Login, JWT issuance, OTP, password-reset internals → auth.
- Customer/supplier party master → crm/_overview (same collection, CRM kinds).
- HR employment data (salary, leave groups, department) → HR
employee. - Self profile & UI preferences → profile-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. |
updateUseris a self-update. The resolver callsthis.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 EmployeeupdateEmployeemutation (which writes the linked user, see §6).deleteAccountis 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/UpdateUserInputdo not exposekind,roles, orgroupId. 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)
- User cap. If
tenantConfigSvc.getMaxUsers() > 0, count existing rows wherekind ∈ {Admin, SuperAdmin, Company, StoreAdmin, Staff}; if>= maxUsersthrow406 "User limit of N reached for this tenant…". Customers/suppliers are exempt. ref = generateUserId()(date-stamped numeric, regenerated on same-day collision),referralId = nanoId(),username ||= email,phoneNumber = removeSpecialChar(phoneNumber).validateExist— see 4.3.- Hash
password || DEFAULT_PASSWORD; setconfirmEmailToken = bcrypt(randomDigits)andconfirmEmailExpiredIn = now + emailTokenTimeSpan days. - 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 byaccountName, Currency by name, Balance) →[UserImport]preview. No persistence.confirmImport({ userKind, users })— inwithRetryTransaction("import_user"):- per row
validateExist, setkeywordsfromname. - look up the seeded access group
findOne({ group: userKind })→ throw if missing. super.create({ ...row, kind: userKind, password: hash(DEFAULT_PASSWORD), groupId, accountId })(accountId= debit for Customer, credit otherwise).- post two opening-balance GL legs (Dr + Cr, direction by kind) with a shared
relationId. accountSvc.validateBalanced()— abort the whole txn if unbalanced.
- per row
4.6 State (status) machine
No state-machine mutation. Two independent flags toggled via updateUser: active: boolean (default true) and status: string (Active ⇄ IN_ACTIVE). Login enforcement of these is in auth.
4.7 Side effects & transactionality
createwrites oneusersrow (+ employee row when called fromEmployeeService.create, which wraps the whole thing in a txn).confirmImportwrites 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.ts → USER_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, '/')ingetServerSideProps. admin-users.tsxis additionally hard-gated tosession.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 (guardview-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 (guardimport-users).
- User module (
src/modules/user) — mostly read/import:gqlexposesUSER_PAGE(userPage),FIND_ONE(findOneUser),IMPORT_USER(importUsers),CONFIRM_USER_IMPORT(confirmUsersImport).context.tsxmethods:fetchUserPage,findOneUser,importUsers,confirmUsersImport.columns.tsxdefines the Name/Account Type/Phone/Email/Currency/Balance grid;layout.tsxis the user-detail chrome. - Employee module (
src/modules/employees) — the create/invite forms.components/create.tsx: Formikuser.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 requiresuser.name,user.email,group._id,store._id. On invite, user fields aredisabled.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;EmployeeServiceinjectsUserServiceand creates thekind=Staffuser.permission/group(AccessGroupService) —groupIdtarget;confirmImportresolves the seeded group byuserKind. Seeded groups:Admin, Salesman, Marketing, Customer, Supplier.finance/account+finance/transaction— import opening-balance legs; controller balance export.master— currency lookup (currencyId).tenant-config—getMaxUsers()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/deleteAccountact on the current user only. Operator edits go throughupdateEmployee. Don't look forupdateUser($id, …)— it isn't there. createUserexists but isn't the operator path. The admin user module mainly does page/find/ import; operators are created via Employee create/invite (which setskind/roles/groupId).CreateUserInputcan't setkind/roles/groupId. A rawcreateUsertherefore lands akind=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. idCheckis a stub — returnsnull; the KYC extraction body is commented out.name&usernameuniqueness aren't re-checked on update — onlyemail/phone/idNumberare.- Delete is a mangle (
deleteAccount), not a hard removal; combine withmongoose-deletefor true soft delete. CRM parties additionally require zero balance (canDelete). - Admin
updateUsersendsaccount:arg but BE binds@Args("user")— cosmetic alias, see _overview §7.