Skip to main content

User Management (User CRUD)

Custom User admin: the AdminJS User resource (new/edit/list/show/delete) is fully driven by custom handlers + a React form, password security is layered, and UserFactoryAccess rows are bulk-synced in one transaction. Introduced by INFRA-646.

For login/auth predicates (adminRoleAuth, adminEditorViewerRoleAuth, …) see admin-lists-and-auth.md; for how UserFactoryAccess.accessLevel sets the effective role at login/factory-switch see step 9 of factory-onboarding.md.

Password security (layered)​

User.passwordHash is a bcrypt hash (hashPassword at src/utils/passwordHashGenerator.ts:12; verified at login via bcrypt.compare in src/providers/admin/auth.provider.ts). The field never round-trips the plaintext — the plain-text password input is write-only. To keep the hash/plaintext from leaking, the code defends at four independent layers — each is necessary; drop one and one leak path opens:

  1. AdminJS column hiding is not enough: passwordHash: { isVisible: false } (src/routers/admin/resources/user/user.ts:18) only hides the column. ⚠️ BaseRecord.toJSON() still serializes passwordHash, so every default action (list/show/delete/bulkDelete) wires after: stripSensitiveParamsAfterHook (user.ts:26,35,39,43), which deletes record.params.passwordHash before the response leaves (src/routers/admin/resources/user/utils.ts:72-91). When adding any new action that serializes a User record, remember to attach this after hook — otherwise the hash lands in the response body.
  2. Model-layer log redaction: handleDbErr embeds Receive Params: ${safeJSONStringify(params)} into the thrown error's message, and that message surfaces in admin notices and logs. UserModel._redactParamsForLog (src/models/user/user.model.ts:62) replaces data.passwordHash with '[REDACTED]' before logging. Both create and update go through it; findUnique/findMany don't (no data).
  3. Service-layer log redaction: logErrorServiceFunc's logParams (src/services/user/user.service.ts:14-33) replaces plainPassword with '[REDACTED]' — the plaintext only ever exists in the inbound params and must never reach any log.
  4. hashPassword is a pure function: this commit turned passwordHashGenerator.ts from an "execute-on-import" script (importing it used to logger.info('Hashed password:' + hash)) into a side-effect-free export async function hashPassword. Any module can now import it safely.

Writers (data writers)​

UserService.createUserWithFactoryAccess / updateUserWithFactoryAccess (src/services/user/user.service.ts:41,74) are the only writers of User records, both wrapped in prisma.$transaction(..., { isolationLevel: Serializable, timeout: 10_000 }) (user.service.ts:38, same default as softDelExtension.makeTransactionOpts). Serializable guards _syncUserFactoryAccess's read-then-sync flow against concurrent edits of the same user.

  • create: hashPassword → UserModel.create → if there's an access list, UserFactoryAccessModel.createMany.
  • update: hashPassword only when plainPassword is non-empty (empty = keep the existing hash), UserModel.update (with conditional spread ...(passwordHash ? { passwordHash } : {}) in data) → _syncUserFactoryAccess.
  • Both entry points are wrapped by logErrorServiceFunc into *LogError static methods returning { result, error }, from which the handler layer renders a notice instead of throwing.
  • As of INFRA-663 only email + language (nullable Language) + passwordHash are written on the User row; the legacy role / factoryId / category writes were removed with the columns themselves.

_syncUserFactoryAccess (user.service.ts:103)​

Syncs the user's factory-access rows to exactly match the submitted factoryAccessList:

  1. findMany({ where: { userId } }) reads existing rows;
  2. deleteMany deletes rows whose factoryId is no longer in the list;
  3. upsertMany bulk-upserts the incoming list (insert new rows, refresh accessLevel on existing ones).

UserFactoryAccess bulk upsert (raw SQL)​

UserFactoryAccessModel.upsertMany (src/models/userFactoryAccess/userFactoryAccess.model.ts:82) issues a $executeRaw INSERT ... VALUES ... ON CONFLICT ("userId","factoryId") DO UPDATE (the @@unique([userId, factoryId]) conflict key), committing the whole list in one statement. createdBy/updatedBy are read from requestContextStorage's current adminUser.email; createdAt/updatedAt share one now timestamp.

Two gotchas:

  • ⚠️ UserFactoryAccess is hard-deleted, not soft-deleted. It has no isDeleted column and is absent from softDelExtension.includeModels (src/models/softDelExtension.ts:23-35 — that list has User but not UserFactoryAccess). So the deleteMany inside _syncUserFactoryAccess is a physical delete, in contrast to User's own delete, which is converted to isDeleted = true. Don't conflate the two deletion semantics.
  • ⚠️ Comment/SQL mismatch: upsertMany does NOT reactivate isActive. The function comment (userFactoryAccess.model.ts:77-81) claims "existing rows ... are reactivated (isActive = true)", but the DO UPDATE SET only updates accessLevel/updatedAt/updatedBy (userFactoryAccess.model.ts:112-115) — there is no "isActive" = true. isActive is the business-level "deactivate" flag (userFactoryAccess.service.ts:38 filters out inactive rows), and _syncUserFactoryAccess's findMany reads inactive rows and does not delete them (their factoryId is still in the list). Therefore a manually deactivated (isActive = false) access row is not reactivated by resubmitting the same factory in the User edit form — after login that factory stays filtered out. To reactivate, edit the row directly in AdminJS or handle it separately.

Primary factory vs access list (UserFactoryAccess) — User.factoryId removed in INFRA-663​

User.factoryId (a nullable FK → Factory, the "primary factory") was removed in INFRA-663 along with User.role and User.category; the only factory data left on a user is the multi-row UserFactoryAccess (one row per accessible factory, each factoryId + accessLevel).

⚠️ The login default selectedFactoryId comes from UserFactoryAccess, full stop: auth.provider.ts:38,45 takes assignedFactories[0] (makeAssignedFactoriesByUserId orders by id asc, lowest id first) as the default factory. There is no separate "primary factory" field anymore — don't assume any other place decides which factory a user lands on.

AdminJS form pattern (newInitParams)​

User's new/edit use a custom React form (src/components/admin/User/UserForm.tsx, registered via the thin UserNew/UserEdit wrappers in src/config/componentLoader.ts:179-180). The dropdown options the form needs (active factories / AccessLevel enum / Language enum) are supplied by one hidden resource action rather than hardcoded in the component or re-queried:

  • newInitParams action (user.ts:50-54): actionType: 'resource', isVisible: false, handler: newInitParamsActionHandler (src/routers/admin/resources/user/handlers.ts:84), returning { factoryOptions, accessLevelOptions, languageOptions } (assembled by makeUserFormMeta, utils.ts:45, which only queries isActive: true factories).
  • Frontend UserForm's useOptions: in new mode it fetches via apiClient.resourceAction({ actionName: 'newInitParams' }); in edit mode it reads from the record params (getUserEditRecord already merged them into recordJSON.params).

The access list itself is submitted as userFactoryAccessListJson (a single JSON-string field) — AdminJS form payloads are flat strings, so the server schema JSON.parses and validates (below). Reusable pattern: to feed a custom form dynamic options, add an isVisible: false resource action that returns the options — cleaner than stuffing them into every record or a global constant.

Validation (Zod, src/schemas/user/index.ts)​

  • language uses z.preprocess to normalize AdminJS's flat-string payload ('' / undefined / null → null) before .nativeEnum(Language).nullable(). The now-removed factoryId / category preprocessors were dropped in INFRA-663 along with the columns.
  • userFactoryAccessListJsonSchema: z.string().default('').transform(JSON.parse); on parse failure or child-schema failure it ctx.addIssue(...)s and return z.NEVER (short-circuits that field as never-valid).
  • UserCreateSchema requires a non-empty password; UserUpdateSchema's password is optional (empty = keep). The dedup lives in parseUserPayload (utils.ts:136), using a Set keyed by issue.message before prettifyZodV3Err.

See also: admin-lists-and-auth.md, factory-onboarding.md.