39948-vm/documentation/user-management.md
2026-07-03 16:11:24 +02:00

25 KiB

User Management

Complete documentation for the Tour Builder Platform's user management system including CRUD operations, invitations, bulk CSV import, and user profiles.

Overview

The platform implements a comprehensive user management system with:

  • CRUD Operations - Create, read, update, delete users with role-based permissions
  • Invitations - Email-based invitation system with password setup tokens
  • Bulk CSV Import - Mass user creation from CSV files
  • User Profiles - Profile management with avatar support
  • Password Management - Password updates, resets, and email verification

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                      User Management Flow                                │
│                                                                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐                 │
│  │  Frontend   │    │   Backend   │    │  Database   │                 │
│  │   Pages     │───▶│   Service   │───▶│   Models    │                 │
│  └─────────────┘    └─────────────┘    └─────────────┘                 │
│        │                  │                   │                         │
│        │ Redux Store      │ UsersService      │ users table            │
│        ▼                  ▼                   ▼                         │
│   usersSlice.ts     users.js            users.js model                 │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                      Invitation Flow                                     │
│                                                                          │
│  Create User ──▶ Generate Token ──▶ Send Email ──▶ User Sets Password  │
│       │               │                  │                │             │
│       ▼               ▼                  ▼                ▼             │
│  UsersService   passwordResetToken  EmailSender    /password-reset     │
└─────────────────────────────────────────────────────────────────────────┘

User Database Model

Schema

File: backend/src/db/models/users.js

Field Type Required Default Description
id UUID Yes Auto Primary key (UUIDv4)
firstName TEXT No null User's first name
lastName TEXT No null User's last name
phoneNumber TEXT No null Phone number
email TEXT Yes - Email (unique, validated)
password TEXT Yes - bcrypt hashed password
disabled BOOLEAN Yes false Account disabled status
emailVerified BOOLEAN Yes false Email verification status
emailVerificationToken TEXT No null Token for email verification
emailVerificationTokenExpiresAt DATE No null Token expiration
passwordResetToken TEXT No null Token for password reset
passwordResetTokenExpiresAt DATE No null Token expiration
provider TEXT Yes 'local' Auth provider (local, google, microsoft)
importHash VARCHAR(255) No null Unique hash for bulk imports
app_roleId UUID No null FK to roles table
createdById UUID No null Audit: creator user
updatedById UUID No null Audit: last updater
createdAt TIMESTAMP Yes Auto Creation timestamp
updatedAt TIMESTAMP Yes Auto Update timestamp
deletedAt TIMESTAMP No null Soft delete timestamp

Indexes:

  • email - Unique index
  • app_roleId - FK index
  • deletedAt - Soft delete queries

Unique Constraints:

  • importHash - Unique constraint on column (for bulk import deduplication)

Relationships

users
├── belongsTo roles (as app_role) ─────────────────── SET NULL on delete
├── belongsToMany permissions (as custom_permissions) ── CASCADE on delete
├── hasMany file (as avatar) ──────────────────────── CASCADE on delete
├── hasMany project_memberships ───────────────────── CASCADE on delete
├── hasMany presigned_url_requests ────────────────── CASCADE on delete
├── hasMany publish_events ────────────────────────── SET NULL on delete
├── hasMany access_logs ───────────────────────────── SET NULL on delete
├── belongsTo users (as createdBy) ────────────────── Audit trail
└── belongsTo users (as updatedBy) ────────────────── Audit trail

Model Hooks

beforeCreate:

// Trim string fields
users.email = users.email.trim();
users.firstName = users.firstName?.trim() || null;
users.lastName = users.lastName?.trim() || null;

// For OAuth users (non-LOCAL provider) that are valid providers:
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
  users.emailVerified = true;  // Auto-verify OAuth emails
  if (!users.password) {
    // Generate random password if not provided
    const password = crypto.randomBytes(20).toString('hex');
    users.password = bcrypt.hashSync(password, config.bcrypt.saltRounds);
  }
}

Note: Provider values are lowercase strings: 'local', 'google', 'microsoft' (stored as providers.LOCAL, etc. from config).

CRUD Operations

Create User

Endpoint: POST /api/users

Permission: CREATE_USERS

Request Body:

{
  "data": {
    "email": "user@example.com",
    "firstName": "John",
    "lastName": "Doe",
    "phoneNumber": "+1-555-1234",
    "disabled": false,
    "password": "optional-initial-password",
    "app_role": "role-uuid",
    "custom_permissions": ["perm-uuid-1", "perm-uuid-2"],
    "avatar": [{ "id": "file-uuid" }]
  }
}

Response: true on success

Service Logic:

// 1. Check if email already exists
let user = await db.users.findOne({ where: { email }, paranoid: false, transaction });
if (user && !user.deletedAt) {
  throw new ValidationError('iam.errors.userAlreadyExists');
}

// 2. Create or restore user record
if (user?.deletedAt) {
  await user.restore({ transaction });
  await UsersDBApi.update({ id: user.id, data, currentUser, transaction });
} else {
  await UsersDBApi.create({ data, currentUser, transaction });
}

// 3. Replace private production presentation grants for Public users
// 4. Send invitation email (if enabled)
AuthService.sendPasswordResetEmail(email, 'invitation', host);

If a user was soft-deleted, PostgreSQL still enforces the unique users.email constraint. Creating a user with that email restores and updates the soft-deleted record instead of inserting a duplicate row.

Default Values Applied:

  • disabled: false
  • emailVerified: true (for admin-created users)
  • app_role: "User" role if not specified
  • provider: local
  • password: generated temporary password when not supplied, stored as bcrypt hash; invitation flow then sends a password-reset link

Read Users

List Users:

GET /api/users?page=1&limit=10&field=createdAt&sort=desc
Authorization: Bearer {token}

Filters:

Parameter Type Description
firstName string Case-insensitive search
lastName string Case-insensitive search
phoneNumber string Case-insensitive search
email string Case-insensitive search
disabled boolean Exact match
emailVerified boolean Exact match
provider string Case-insensitive search
app_role string Role ID or name (pipe-separated for multiple)
custom_permissions string Permission ID or name (pipe-separated)
createdAtRange array [startDate, endDate]

Response:

{
  "rows": [
    {
      "id": "uuid",
      "firstName": "John",
      "lastName": "Doe",
      "email": "john@example.com",
      "disabled": false,
      "emailVerified": true,
      "provider": "LOCAL",
      "app_role": { "id": "uuid", "name": "User" },
      "custom_permissions": [],
      "avatar": [{ "id": "uuid", "publicUrl": "..." }],
      "createdAt": "2025-01-01T00:00:00Z"
    }
  ],
  "count": 100
}

Single User:

GET /api/users/{id}
Authorization: Bearer {token}

Note: Password field is explicitly removed from response.

Autocomplete:

GET /api/users/autocomplete?query=john&limit=10&offset=0
Authorization: Bearer {token}

Returns [{ id: "uuid", label: "John" }] format.

Count:

GET /api/users/count
Authorization: Bearer {token}

CSV Export:

GET /api/users?filetype=csv
Authorization: Bearer {token}

Returns CSV with columns: id, firstName, lastName, phoneNumber, email

Update User

Endpoint: PUT /api/users/{id}

Permission: UPDATE_USERS

Request Body:

{
  "id": "user-uuid",
  "data": {
    "firstName": "Updated Name",
    "password": "new-password",
    "app_role": "new-role-uuid",
    "custom_permissions": ["perm-uuid"],
    "avatar": [{ "id": "file-uuid" }]
  }
}

Password Handling:

  • If password provided: Hashed with bcrypt before storing
  • If not provided: Existing password preserved

emailVerified Behavior:

  • If not specified in update: Defaults to true
  • Can be explicitly set to false if needed

Delete User

Single Delete:

DELETE /api/users/{id}
Authorization: Bearer {token}

Validations:

  • Cannot delete yourself: iam.errors.deletingHimself
  • Must have Administrator role: errors.forbidden.message

Bulk Delete:

POST /api/users/deleteByIds
Authorization: Bearer {token}

{
  "data": ["user-uuid-1", "user-uuid-2"]
}

Note: Uses soft delete (sets deletedAt timestamp).

Bulk delete: Handled by the factory-generated UsersService.deleteByIds({ ids, currentUser, runtimeContext }) method and delegated to UsersDBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext }).

Invitation System

How Invitations Work

  1. Admin creates a user via UI or API
  2. System generates a password reset token
  3. Invitation email sent with setup link
  4. User clicks link and sets their password
  5. Account is activated

Token Generation

File: backend/src/db/api/users.js

static async generatePasswordResetToken(email, options) {
  const token = crypto.randomBytes(20).toString('hex');  // 40-char hex
  const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;  // 24 hours
  const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;

  await users.update({
    passwordResetToken: token,
    passwordResetTokenExpiresAt: tokenExpiresAt,
  });

  return token;
}

Token Properties:

Property Value
Format 40-character hexadecimal
Expiration 24 hours
Storage passwordResetToken field
Reusable Yes (token NOT cleared after use - see Known Issues)

Invitation Email

Email Service: backend/src/services/email/list/invitation.js

Template: backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html

Email Content:

  • Subject: Invitation to join the platform
  • Body: Welcome message with "Set up account" button
  • Link: {host}/password-reset?token={token}&invitation=true

Template Variables:

Variable Description
{appTitle} Application name
{signupUrl} Password reset URL with token
{to} Recipient email address

Sending Invitations

// Called from UsersService.create()
AuthService.sendPasswordResetEmail(email, 'invitation', host);

Email Service Configuration:

// backend/src/config.ts
email: {
  from: 'Tour Builder Platform <app@flatlogic.app>',
  host: 'email-smtp.us-east-1.amazonaws.com',
  port: 587,
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS,
  }
}

Note: If email is not configured (EmailSender.isConfigured returns false), invitations are silently skipped.

Bulk CSV Import

Endpoint

POST /api/users/bulk-import
Content-Type: multipart/form-data
Authorization: Bearer {token}

Permission: CREATE_USERS

CSV Format

Required Fields:

  • email - Must be present in every row

Optional Fields:

  • firstName
  • lastName
  • phoneNumber
  • disabled (true/false)
  • password
  • provider

Example CSV:

firstName,lastName,email,phoneNumber,disabled
John,Doe,john@example.com,555-1234,false
Jane,Smith,jane@example.com,555-5678,false
Alice,Johnson,alice@example.com,555-9012,false

Import Process

File: backend/src/services/users.ts

static async bulkImport(req, res, sendInvitationEmails = true, host) {
  // 1. Parse CSV file
  await processFile(req, res);
  const bufferStream = new stream.PassThrough();
  bufferStream.end(Buffer.from(req.file.buffer, "utf-8"));

  const results = [];
  await new Promise((resolve, reject) => {
    bufferStream
      .pipe(csv())
      .on('data', (data) => results.push(data))
      .on('end', () => resolve())
      .on('error', (error) => reject(error));
  });

  // 2. Validate all rows have email
  const hasAllEmails = results.every((result) => result.email);
  if (!hasAllEmails) {
    throw new ValidationError('importer.errors.userEmailMissing');
  }

  // 3. Bulk create users
  await UsersDBApi.bulkImport(results, { transaction, currentUser });

  // 4. Send invitation emails
  emailsToInvite.forEach((email) => {
    AuthService.sendPasswordResetEmail(email, 'invitation', host);
  });
}

Bulk Import Behavior

Aspect Behavior
Default Role "User" role assigned to all imported users
emailVerified false (unlike single create which defaults to true)
Duplicate Handling ignoreDuplicates: true - skips existing emails
Transaction All-or-nothing: rollback on any error
Invitation Emails Sent to all imported email addresses

Known Issue: The invitation email logic has inverted condition:

// BUG: Emails only sent when sendInvitationEmails is FALSE
if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) {
  emailsToInvite.forEach((email) => {
    AuthService.sendPasswordResetEmail(email, 'invitation', host);
  });
}

Frontend CSV Import

Component: Uses DragDropFilePicker in list page

Redux Action:

dispatch(uploadCsv(file));
dispatch(setRefetch(true));  // Refresh table after import

Download Existing Users as CSV:

// Button click handler
window.open('/api/users?filetype=csv', '_blank');

User Profiles

Profile Update Endpoint

PUT /api/auth/profile
Authorization: Bearer {token}

{
  "firstName": "John",
  "lastName": "Doe",
  "phoneNumber": "+1-555-1234",
  "avatar": [{ "id": "file-uuid" }]
}

Service: AuthService.updateProfile(profile, currentUser)

Updatable Profile Fields

Field Type Notes
firstName string Trimmed on save
lastName string Trimmed on save
phoneNumber string No validation
email string Can be changed
avatar file[] Via FileDBApi

Avatar Handling

Storage Configuration:

{
  belongsTo: 'users',
  belongsToColumn: 'avatar',
  belongsToId: users.id
}

File Upload:

  • Uses chunked upload system (see Asset Upload documentation)
  • Path scoped to users/avatar
  • Stored in file table with FK to user

Frontend Component: FormImagePicker with path='users/avatar'

Password Management

Password Update (Authenticated)

PUT /api/auth/password-update
Authorization: Bearer {token}

{
  "currentPassword": "old-password",
  "newPassword": "new-password"
}

Validations:

  • Current password must match stored hash
  • New password cannot be same as current password

Errors:

Error Code Description
auth.wrongPassword Current password incorrect
auth.passwordUpdate.samePassword New password same as current

Password Reset (Forgot Password)

Step 1: Request Reset Email

POST /api/auth/send-password-reset-email

{ "email": "user@example.com" }

Rate Limit: 5 requests per hour per IP

Step 2: Reset Password

PUT /api/auth/password-reset

{
  "token": "40-char-hex-token",
  "password": "new-password"
}

Token Validation:

  • Must exist in database
  • Must not be expired (passwordResetTokenExpiresAt > Date.now())

Error: auth.passwordReset.invalidToken

Password Hashing

Algorithm: bcrypt

Configuration:

// backend/src/config.ts
bcrypt: {
  saltRounds: 12
}

Applied In:

  • User model beforeCreate hook
  • UsersDBApi.update() when password field present
  • UsersDBApi.updatePassword()
  • Password reset/setup flow

Email Verification

Generate Verification Token

UsersDBApi.generateEmailVerificationToken(email);

Same format as password reset token: 40-char hex, 24-hour expiration.

Verify Email

PUT /api/auth/verify-email

{ "token": "verification-token" }

Process:

  1. Find user by emailVerificationToken
  2. Check emailVerificationTokenExpiresAt > Date.now()
  3. Set emailVerified = true
  4. Clear verification token

Request Verification Email

POST /api/auth/send-email-address-verification-email
Authorization: Bearer {token}

Sends email with verification link to current user's email.

Role & Permission Assignment

Default Role Assignment

Single User Creation:

if (!data.app_role) {
  const role = await db.roles.findOne({ where: { name: 'User' } });
  if (role) {
    await users.setApp_role(role);
  }
} else {
  await users.setApp_role(data.app_role);
}

Bulk Import: All users receive "User" role (no per-user role in CSV).

Self-Registration:

Self-registration is disabled. New users are created by Administrator, Platform Owner, or Account Manager through the Users flow, with the role selected in the user form.

Custom Permissions

Users can have additional permissions beyond their role:

// Assign custom permissions
await users.setCustom_permissions(permissionIds);

Junction Table: usersCustom_permissionsPermissions

Permission Resolution:

// User's effective permissions = role.permissions + custom_permissions
const permissions = new Set([
  ...(user.app_role?.permissions || []),
  ...(user.custom_permissions || [])
]);

API Reference

Endpoints Summary

Method Endpoint Permission Description
POST /api/users CREATE_USERS Create user
POST /api/users/bulk-import CREATE_USERS Bulk import CSV
GET /api/users READ_USERS List users
GET /api/users/count READ_USERS Count users
GET /api/users/autocomplete READ_USERS Autocomplete search
GET /api/users/{id} READ_USERS Get single user
PUT /api/users/{id} UPDATE_USERS Update user
DELETE /api/users/{id} DELETE_USERS + Admin role Delete user (see note)
POST /api/users/deleteByIds CREATE_USERS Bulk delete (POST → CREATE)
PUT /api/auth/profile Authenticated Update own profile
PUT /api/auth/password-update Authenticated Change password
POST /api/auth/send-password-reset-email Public (rate limited) Request reset
PUT /api/auth/password-reset Public Reset password
PUT /api/auth/verify-email Public Verify email

Note on DELETE: Single user delete requires BOTH:

  1. DELETE_USERS permission (via checkCrudPermissions middleware)
  2. Administrator role name check (in UsersService.remove())

Self-Access Bypass: Users can access their own resources via GET/PUT without specific permissions if their user ID matches the resource ID.

Permission Middleware

All /api/users routes use:

router.use(checkCrudPermissions('users'));

This applies:

  • CREATE_USERS for POST
  • READ_USERS for GET
  • UPDATE_USERS for PUT
  • DELETE_USERS for DELETE

Frontend Implementation

Redux Store

File: frontend/src/stores/users/usersSlice.ts

Actions:

Action Description
fetch({ id, query }) Get single user or list
create(data) Create new user
update({ id, data }) Update user
deleteItem(id) Delete single user
deleteItemsByIds(ids) Bulk delete
uploadCsv(file) Import CSV
setRefetch(true) Trigger table refresh

Pages

Location: frontend/src/pages/users/

Page File Description
List users-list.tsx (29 LOC) User list with search, CSV import (factory-generated)
Table users-table.tsx (168 LOC) Alternative table view (manual implementation)
New users-new.tsx (155 LOC) Create user form
Edit (query) users-edit.tsx (168 LOC) Edit user form (query param), including Public user private presentation grants
Edit (path) [usersId].tsx (197 LOC) Edit user form (path param)
View users-view.tsx (524 LOC) Read-only user details

Note: Two edit routes exist - query-based (?id=) and dynamic path-based ([usersId].tsx).

Table Configuration

Components: frontend/src/components/Users/

  • TableUsers.tsx - Data grid component (23 LOC)
  • CardUsers.tsx - Card view component (194 LOC)
  • ListUsers.tsx - List view component (145 LOC)
  • configureUsersCols.tsx - Column definitions with permission-based editability (63 LOC)

Columns:

Column Type Editable
firstName text Yes
lastName text Yes
phoneNumber text Yes
email text Yes
disabled boolean toggle Yes
avatar image No
app_role select dropdown Yes
custom_permissions multi-select No
actions icons -

File Reference

Backend Files

File LOC Purpose
backend/src/db/models/users.js 230 User model definition
backend/src/db/api/users.js 734 User database operations
backend/src/services/users.ts ~350 User business logic
backend/src/routes/users.ts 64 User API routes
backend/src/services/auth.ts - Authentication & invitations
backend/src/services/email/list/invitation.js - Invitation email
backend/src/services/email/htmlTemplates/invitation/ - Email template

Frontend Files

File LOC Purpose
frontend/src/stores/users/usersSlice.ts 25 User Redux store
frontend/src/pages/users/ 1241 User management pages (6 files)
frontend/src/components/Users/ 425 User UI components (4 files)

Known Issues

1. emailVerified Default Inconsistency

Issue: Single user create defaults emailVerified to true, but bulk import defaults to false.

Impact: Bulk imported users may not be able to login if email verification is required.

2. Password Reset Token Not Cleared After Use

Issue: The passwordResetToken is not cleared from the database after a successful password reset.

Location: backend/src/services/auth.ts and backend/src/db/api/users.js

Impact: The same password reset token can be reused multiple times within the 24-hour expiration window to reset the user's password again.

Security Risk: If a reset link is compromised, an attacker could reset the password multiple times.

Fix: Clear the passwordResetToken and passwordResetTokenExpiresAt fields in UsersDBApi.updatePassword() or in AuthService.passwordReset() after successful password update.

Troubleshooting

User Cannot Login

  1. Check disabled is false
  2. Check emailVerified is true (or email service not configured)
  3. Verify password hash is set correctly
  4. Check provider matches login method (LOCAL vs OAuth)

Invitation Email Not Received

  1. Verify email service is configured (EmailSender.isConfigured)
  2. Check spam/junk folder
  3. Verify SMTP credentials in .env
  4. Check backend logs for email sending errors

Bulk Import Fails

  1. Ensure CSV has email column for all rows
  2. Check for duplicate emails (will be skipped)
  3. Verify CSV encoding (UTF-8 recommended)
  4. Check file size limits

Password Reset Token Invalid

  1. Token may be expired (24-hour limit)
  2. Token doesn't exist in database
  3. Check for URL encoding issues in token

Note: Due to Known Issue #4, tokens are NOT cleared after use and can be reused within the expiration window.