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

844 lines
25 KiB
Markdown

# 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 <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
```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.