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 |
| 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 indexapp_roleId- FK indexdeletedAt- 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: falseemailVerified: true (for admin-created users)app_role: "User" role if not specifiedprovider:localpassword: 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 |
| 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
falseif 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
- Admin creates a user via UI or API
- System generates a password reset token
- Invitation email sent with setup link
- User clicks link and sets their password
- 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:
firstNamelastNamephoneNumberdisabled(true/false)passwordprovider
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 |
| 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
filetable 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
beforeCreatehook UsersDBApi.update()when password field presentUsersDBApi.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:
- Find user by
emailVerificationToken - Check
emailVerificationTokenExpiresAt > Date.now() - Set
emailVerified = true - 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:
DELETE_USERSpermission (viacheckCrudPermissionsmiddleware)Administratorrole name check (inUsersService.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_USERSfor POSTREAD_USERSfor GETUPDATE_USERSfor PUTDELETE_USERSfor 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 |
| 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
- Check
disabledisfalse - Check
emailVerifiedistrue(or email service not configured) - Verify password hash is set correctly
- Check
providermatches login method (LOCAL vs OAuth)
Invitation Email Not Received
- Verify email service is configured (
EmailSender.isConfigured) - Check spam/junk folder
- Verify SMTP credentials in
.env - Check backend logs for email sending errors
Bulk Import Fails
- Ensure CSV has
emailcolumn for all rows - Check for duplicate emails (will be skipped)
- Verify CSV encoding (UTF-8 recommended)
- Check file size limits
Password Reset Token Invalid
- Token may be expired (24-hour limit)
- Token doesn't exist in database
- 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.