138 lines
8.2 KiB
Markdown
138 lines
8.2 KiB
Markdown
# Users Backend
|
|
|
|
## Purpose
|
|
|
|
`users` is the identity slice: it manages user accounts, their assigned role (`app_role`),
|
|
per-user custom permissions, organization membership, optional avatar files, and the
|
|
email-action tokens used for verification and password reset. The slice is hand-written: creating
|
|
a user (or bulk-importing users) triggers an invitation email containing a password-reset link.
|
|
|
|
## Slice Files (by layer)
|
|
|
|
- Route: `src/routes/users.ts` (CRUD plus `bulk-import`, `count`, `autocomplete`, `deleteByIds`;
|
|
applies `checkCrudPermissions('users')` to every route).
|
|
- Controller: `src/api/controllers/users.controller.ts` (resolves the UI host from the request
|
|
`Referer` for invitation links; handles CSV export and file upload).
|
|
- Service (BLL): `src/services/users.ts` (invitation + bulk-import workflow; duplicate-email and
|
|
self-delete guards; delegates email sending to `AuthService`).
|
|
- Repository (DAL): `src/db/api/users.ts` (also exposes auth helpers: `findBy`,
|
|
`findProfileById`, `createFromAuth`, `updatePassword`, token generation/lookup,
|
|
`markEmailVerified`).
|
|
- Model: `src/db/models/users.ts`.
|
|
- Shared used: `services/auth.ts` (`AuthService.sendPasswordResetEmail`),
|
|
`db/api/shared/repository.ts`, `db/api/file.ts` (`replaceRelationFiles`), `db/utils.ts`,
|
|
`shared/config.ts` (`config.roles`, `config.providers`, bcrypt settings),
|
|
`shared/constants/roles.ts` (`ROLE_NAMES`), `shared/constants/auth.ts`
|
|
(`EMAIL_ACTION_TOKEN_BYTES`, `EMAIL_ACTION_TOKEN_TTL_MS`),
|
|
`shared/constants/database.ts` (`BULK_IMPORT_TIMESTAMP_STEP_MS`),
|
|
`shared/constants/pagination.ts` (`resolvePagination`), `shared/csv.ts` (`toCsv`),
|
|
`middlewares/upload.ts` (`processFile`), `middlewares/check-permissions.ts`,
|
|
`shared/errors/validation.ts`.
|
|
|
|
## API
|
|
|
|
All routes are mounted under `/api/users` and require JWT authentication (`src/index.ts`). Every
|
|
route passes `checkCrudPermissions('users')`, requiring the permission `${METHOD}_USERS`
|
|
(see `permissions.md`); the middleware's self-access bypass also lets a user act on their own
|
|
record when `req.params.id`/`req.body.id` equals their own id.
|
|
|
|
- `POST /api/users` -> `200` `true`. Request body: `{ data: <UserInput> }`. Creates the user then
|
|
sends an invitation email.
|
|
- `POST /api/users/bulk-import` -> `200` `true`. Multipart CSV upload; every row must carry an
|
|
`email`. Missing file raises `ValidationError('importer.errors.invalidFileEmpty')`.
|
|
- `PUT /api/users/:id` -> `200` `true`. The controller calls `Service.update(req.body.data,
|
|
req.body.id, ...)` (reads `req.body.id`, not `req.params.id`).
|
|
- `DELETE /api/users/:id` -> `200` `true`.
|
|
- `POST /api/users/deleteByIds` -> `200` `true`. Request body: `{ data: string[] }`.
|
|
- `GET /api/users` -> `200` `{ rows, count }`. When `?filetype=csv`, responds with a CSV
|
|
attachment of fields `id, firstName, lastName, phoneNumber, email`.
|
|
- `GET /api/users/count` -> `200` `{ rows: [], count }`.
|
|
- `GET /api/users/autocomplete` -> `200` array of `{ id, label }`.
|
|
- `GET /api/users/:id` -> `200`, a single eager-loaded user record (role + permissions, staff
|
|
profile, custom permissions, organization).
|
|
|
|
## Access Rules
|
|
|
|
- CRUD is gated by `checkCrudPermissions('users')` (`${METHOD}_USERS`), or a matching custom
|
|
per-user permission, or the self-access bypass.
|
|
- `remove` adds explicit service-level guards beyond the permission check:
|
|
- A user cannot delete themselves: `currentUser.id === id` raises
|
|
`ValidationError('iam.errors.deletingHimself')`.
|
|
- Only roles named `config.roles.admin` or `config.roles.super_admin` may delete a user;
|
|
otherwise `ValidationError('errors.forbidden.message')`.
|
|
- `create` rejects a duplicate email (`iam.errors.userAlreadyExists`) and a missing email
|
|
(`iam.errors.emailRequired`).
|
|
|
|
## Tenant Scope
|
|
|
|
- `findAll` scopes to `currentUser.organizationId` only when the user has a loaded
|
|
`organizations` association and an `organizationId`. `globalAccess` users
|
|
(`currentUser.app_role.globalAccess`) have the org constraint removed and read across
|
|
organizations.
|
|
- On `create`, organization membership is set from `data.organizations` via `setOrganizations`.
|
|
- On `update`, role/org/custom-permission associations are only changed when their respective
|
|
fields are present in the input.
|
|
|
|
## Data Contract
|
|
|
|
Model columns (`src/db/models/users.ts`): `id` (UUID PK), `firstName`, `lastName`, `phoneNumber`
|
|
(text), `email` (text, `allowNull: false`), `disabled` (boolean, default false), `password`
|
|
(text), `emailVerified` (boolean, default false), `emailVerificationToken` +
|
|
`emailVerificationTokenExpiresAt`, `passwordResetToken` + `passwordResetTokenExpiresAt`,
|
|
`provider` (text), `importHash` (unique), `organizationId`, `createdById`, `updatedById`,
|
|
`createdAt`, `updatedAt`, `deletedAt` (paranoid soft-delete). A `beforeCreate`/`beforeUpdate`
|
|
hook trims `email`/`firstName`/`lastName`; for non-local OAuth providers `beforeCreate` forces
|
|
`emailVerified = true` and generates a random bcrypt password when none is supplied.
|
|
|
|
Associations: `belongsToMany permissions` as `custom_permissions` (and `custom_permissions_filter`
|
|
for list filtering) through `usersCustom_permissionsPermissions`; `hasMany staff` as `staff_user`;
|
|
`hasMany messages` as `messages_sent_by`; `belongsTo roles` as `app_role`; `belongsTo
|
|
organizations` as `organizations`; `hasMany file` as `avatar`; `belongsTo users` as
|
|
`createdBy`/`updatedBy`.
|
|
|
|
On `create`/`bulkImport` the repository sets `emailVerified` to `true` on single create and to
|
|
`false` (unless supplied) on bulk import. A user created without an explicit `app_role` has no role and falls back to the `guest` role
|
|
until one is assigned (roles are assigned explicitly by the provisioning flow). The service
|
|
layer (`services/users.ts`) also enforces the relational role policy
|
|
(`assertCanManageUserWithRole`), a same-organization guard (`assertSameTenant`) for non-global
|
|
actors, and auto-creates the company when an `owner` is created (§3.3/§3.4).
|
|
|
|
List filters (`findAll`): `id`, `firstName`, `lastName`, `phoneNumber`, `email`, `password`,
|
|
`emailVerificationToken`, `passwordResetToken`, `provider` (ILIKE);
|
|
`emailVerificationTokenExpiresAtRange`, `passwordResetTokenExpiresAtRange`, `createdAtRange`;
|
|
`active`, `disabled`, `emailVerified`; `app_role` (join on id or name, `|`-separated);
|
|
`organizations` (`|`-separated ids); `custom_permissions` (join on id or name); plus `field`/`sort`
|
|
(default `createdAt desc`) and `limit`/`page`.
|
|
|
|
## Behavior / Notes
|
|
|
|
- Invitation workflow: after a successful `create` commit, `AuthService.sendPasswordResetEmail`
|
|
is called with type `'invitation'` and the host resolved from the request `Referer`, sending the
|
|
new user a `/password-reset?token=...` link. The token is generated by
|
|
`UsersDBApi.generatePasswordResetToken` with `EMAIL_ACTION_TOKEN_BYTES`/`EMAIL_ACTION_TOKEN_TTL_MS`.
|
|
- Bulk import: parses the uploaded CSV (`csv-parser`); requires every row to carry an `email`
|
|
(else `ValidationError('importer.errors.userEmailMissing')`); `bulkCreate`s with
|
|
`ignoreDuplicates: true` and staggered `createdAt` (`BULK_IMPORT_TIMESTAMP_STEP_MS`); attaches
|
|
avatar files per row.
|
|
- Note (inconsistency in source): both `create` and `bulkImport` accept a `sendInvitationEmails`
|
|
flag (controller passes `true`). In `create`, emails are sent only when `sendInvitationEmails`
|
|
is truthy (`if (!sendInvitationEmails) return;`), but in `bulkImport` the email loop runs only
|
|
when `!sendInvitationEmails`. With the controller always passing `true`, single-create users are
|
|
emailed while bulk-imported users are not.
|
|
- All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
|
|
- `findBy` returns a per-request `AuthenticatedUser` (role + its permissions, staff profile,
|
|
custom permissions, organization) used by authentication/authorization; `findProfileById`
|
|
returns the trimmed profile DTO for `GET /me`.
|
|
|
|
## Tests
|
|
|
|
None yet (no `users` unit/e2e test under `src/`).
|
|
|
|
## Related
|
|
|
|
- Backend slices: `permissions.md` (the `${METHOD}_USERS` gate and the `custom_permissions` /
|
|
`app_role.permissions` model consumed by `check-permissions.ts`); the `roles` entity
|
|
(`app_role`; a user created without a role falls back to `guest`).
|
|
- Frontend / auth: `auth-profile.md` (the profile DTO produced by `findProfileById`, plus the
|
|
invitation/password-reset email flow shared with `AuthService`).
|