# 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:** ```javascript // 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:** ```json { "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:** ```javascript // 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:** ```http 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:** ```json { "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:** ```http GET /api/users/{id} Authorization: Bearer {token} ``` **Note:** Password field is explicitly removed from response. **Autocomplete:** ```http GET /api/users/autocomplete?query=john&limit=10&offset=0 Authorization: Bearer {token} ``` Returns `[{ id: "uuid", label: "John" }]` format. **Count:** ```http GET /api/users/count Authorization: Bearer {token} ``` **CSV Export:** ```http 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:** ```json { "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:** ```http DELETE /api/users/{id} Authorization: Bearer {token} ``` **Validations:** - Cannot delete yourself: `iam.errors.deletingHimself` - Must have Administrator role: `errors.forbidden.message` **Bulk Delete:** ```http 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` ```javascript 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 ```javascript // Called from UsersService.create() AuthService.sendPasswordResetEmail(email, 'invitation', host); ``` **Email Service Configuration:** ```javascript // backend/src/config.ts email: { from: 'Tour Builder Platform ', 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 ```http 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:** ```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` ```javascript 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: ```javascript // 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:** ```typescript dispatch(uploadCsv(file)); dispatch(setRefetch(true)); // Refresh table after import ``` **Download Existing Users as CSV:** ```typescript // Button click handler window.open('/api/users?filetype=csv', '_blank'); ``` ## User Profiles ### Profile Update Endpoint ```http 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:** ```javascript { 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) ```http 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** ```http POST /api/auth/send-password-reset-email { "email": "user@example.com" } ``` **Rate Limit:** 5 requests per hour per IP **Step 2: Reset Password** ```http 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:** ```javascript // 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 ```javascript UsersDBApi.generateEmailVerificationToken(email); ``` Same format as password reset token: 40-char hex, 24-hour expiration. ### Verify Email ```http 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 ```http 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:** ```javascript 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: ```javascript // Assign custom permissions await users.setCustom_permissions(permissionIds); ``` **Junction Table:** `usersCustom_permissionsPermissions` **Permission Resolution:** ```javascript // 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: ```javascript 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.