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, …) seeadmin-lists-and-auth.md; for howUserFactoryAccess.accessLevelsets the effective role at login/factory-switch see step 9 offactory-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:
- AdminJS column hiding is not enough:
passwordHash: { isVisible: false }(src/routers/admin/resources/user/user.ts:18) only hides the column. ⚠️BaseRecord.toJSON()still serializespasswordHash, so every default action (list/show/delete/bulkDelete) wiresafter: stripSensitiveParamsAfterHook(user.ts:26,35,39,43), whichdeletesrecord.params.passwordHashbefore 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 thisafterhook — otherwise the hash lands in the response body. - Model-layer log redaction:
handleDbErrembedsReceive 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) replacesdata.passwordHashwith'[REDACTED]'before logging. Both create and update go through it; findUnique/findMany don't (nodata). - Service-layer log redaction:
logErrorServiceFunc'slogParams(src/services/user/user.service.ts:14-33) replacesplainPasswordwith'[REDACTED]'— the plaintext only ever exists in the inbound params and must never reach any log. hashPasswordis a pure function: this commit turnedpasswordHashGenerator.tsfrom an "execute-on-import" script (importing it used tologger.info('Hashed password:' + hash)) into a side-effect-freeexport 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:
hashPasswordonly whenplainPasswordis non-empty (empty = keep the existing hash),UserModel.update(with conditional spread...(passwordHash ? { passwordHash } : {})indata) →_syncUserFactoryAccess. - Both entry points are wrapped by
logErrorServiceFuncinto*LogErrorstatic methods returning{ result, error }, from which the handler layer renders a notice instead of throwing. - As of INFRA-663 only
email+language(nullableLanguage) +passwordHashare written on the User row; the legacyrole/factoryId/categorywrites were removed with the columns themselves.
_syncUserFactoryAccess (user.service.ts:103)
Syncs the user's factory-access rows to exactly match the submitted factoryAccessList:
findMany({ where: { userId } })reads existing rows;deleteManydeletes rows whosefactoryIdis no longer in the list;upsertManybulk-upserts the incoming list (insert new rows, refreshaccessLevelon 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:
- ⚠️
UserFactoryAccessis hard-deleted, not soft-deleted. It has noisDeletedcolumn and is absent fromsoftDelExtension.includeModels(src/models/softDelExtension.ts:23-35— that list hasUserbut notUserFactoryAccess). So thedeleteManyinside_syncUserFactoryAccessis a physical delete, in contrast toUser's own delete, which is converted toisDeleted = true. Don't conflate the two deletion semantics. - ⚠️ Comment/SQL mismatch:
upsertManydoes NOT reactivateisActive. The function comment (userFactoryAccess.model.ts:77-81) claims "existing rows ... are reactivated (isActive = true)", but theDO UPDATE SETonly updatesaccessLevel/updatedAt/updatedBy(userFactoryAccess.model.ts:112-115) — there is no"isActive" = true.isActiveis the business-level "deactivate" flag (userFactoryAccess.service.ts:38filters out inactive rows), and_syncUserFactoryAccess'sfindManyreads inactive rows and does not delete them (theirfactoryIdis 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:
newInitParamsaction (user.ts:50-54):actionType: 'resource',isVisible: false,handler: newInitParamsActionHandler(src/routers/admin/resources/user/handlers.ts:84), returning{ factoryOptions, accessLevelOptions, languageOptions }(assembled bymakeUserFormMeta,utils.ts:45, which only queriesisActive: truefactories).- Frontend
UserForm'suseOptions: innewmode it fetches viaapiClient.resourceAction({ actionName: 'newInitParams' }); ineditmode it reads from the record params (getUserEditRecordalready merged them intorecordJSON.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)
languageusesz.preprocessto normalize AdminJS's flat-string payload (''/undefined/null→null) before.nativeEnum(Language).nullable(). The now-removedfactoryId/categorypreprocessors were dropped in INFRA-663 along with the columns.userFactoryAccessListJsonSchema:z.string().default('').transform(JSON.parse); on parse failure or child-schema failure itctx.addIssue(...)s andreturn z.NEVER(short-circuits that field as never-valid).UserCreateSchemarequires a non-empty password;UserUpdateSchema's password is optional (empty = keep). The dedup lives inparseUserPayload(utils.ts:136), using aSetkeyed byissue.messagebeforeprettifyZodV3Err.
See also: admin-lists-and-auth.md,
factory-onboarding.md.