19 KiB
Users Module
Overview
The Users module provides comprehensive user management with role-based access control, pagination, search, and filtering. It supports CRUD operations with granular permissions allowing admins to manage all users while regular users can only view/update their own profile.
Key Features:
- Full CRUD operations with role-based permissions
- Paginated list with search (email, name) and filters (role, status)
- Self-service profile updates for regular users
- Admin-only role and status changes
- Password hashing with bcrypt (12 salt rounds)
- Temporary user expiration handling
- Email uniqueness validation
- Cascading delete for related records
File Paths
| File | Purpose |
|---|---|
api/src/modules/users/users.routes.ts |
Express router with 5 CRUD endpoints |
api/src/modules/users/users.service.ts |
User management business logic |
api/src/modules/users/users.schemas.ts |
Zod validation schemas |
Database Model
The Users module uses the User model from the Auth module:
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
phone String?
role UserRole @default(USER)
status UserStatus @default(ACTIVE)
permissions Json?
createdVia String? @default("web")
emailVerified Boolean @default(false)
expiresAt DateTime? // For TEMP users
expireDays Int? // Days until expiration
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
refreshTokens RefreshToken[]
createdCampaigns Campaign[] @relation("CreatedBy")
createdLocations Location[] @relation("CreatedBy")
// ... other relations
}
enum UserRole {
SUPER_ADMIN // Full system access
INFLUENCE_ADMIN // Campaign management
MAP_ADMIN // Location/canvass management
USER // Standard authenticated user
TEMP // Temporary user (e.g., shift signups)
}
enum UserStatus {
ACTIVE // Normal operation
SUSPENDED // Temporarily disabled
BANNED // Permanently disabled
}
API Endpoints
| Method | Path | Auth | Permissions | Description |
|---|---|---|---|---|
| GET | /api/users |
Required | Admin roles | List users with pagination/filters |
| GET | /api/users/:id |
Required | Admin or self | Get user by ID |
| POST | /api/users |
Required | Admin roles | Create new user |
| PUT | /api/users/:id |
Required | Admin or self | Update user |
| DELETE | /api/users/:id |
Required | Admin roles | Delete user |
Admin Roles: SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN
Endpoint Details
GET /api/users
List users with pagination, search, and filtering (admin only).
Request Headers:
Authorization: Bearer <access_token>
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| page | number | No | 1 | Page number (1-indexed) |
| limit | number | No | 20 | Results per page (max 100) |
| search | string | No | - | Search email or name (case-insensitive) |
| role | UserRole | No | - | Filter by role |
| status | UserStatus | No | - | Filter by status |
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE"
Response (200 OK):
{
"users": [
{
"id": "clx1234567890",
"email": "john.doe@example.com",
"name": "John Doe",
"phone": "+1234567890",
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"expiresAt": null,
"expireDays": null,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"emailVerified": true,
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}
Implementation:
router.get(
'/',
requireRole(...ADMIN_ROLES),
validate(listUsersSchema, 'query'),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await usersService.findAll(req.query as any);
res.json(result);
} catch (err) {
next(err);
}
}
);
Search Logic:
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
];
}
GET /api/users/:id
Get user by ID. Admins can view any user, regular users can only view themselves.
Request Headers:
Authorization: Bearer <access_token>
Path Parameters:
id(string): User ID
Example Request:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/users/clx1234567890"
Response (200 OK):
{
"id": "clx1234567890",
"email": "john.doe@example.com",
"name": "John Doe",
"phone": "+1234567890",
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"expiresAt": null,
"expireDays": null,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"emailVerified": true,
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
Error Responses:
403 Forbidden: Non-admin trying to view another user404 Not Found: User ID does not exist
Permission Logic:
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
const isSelf = req.user!.id === id;
if (!isAdmin && !isSelf) {
res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
return;
}
POST /api/users
Create new user account (admin only). Unlike public registration, admins can set any role.
Request Headers:
Authorization: Bearer <access_token>
Content-Type: application/json
Request Body:
{
"email": "newuser@example.com",
"password": "TempPass123",
"name": "New User",
"phone": "+1234567890",
"role": "MAP_ADMIN",
"status": "ACTIVE",
"expiresAt": "2026-12-31T23:59:59Z",
"expireDays": 365
}
Field Details:
| Field | Type | Required | Description |
|---|---|---|---|
| string | Yes | Unique email address | |
| password | string | Yes | Minimum 8 characters (admin creation has relaxed policy) |
| name | string | No | Full name |
| phone | string | No | Phone number |
| role | UserRole | No | Default: USER |
| status | UserStatus | No | Default: ACTIVE |
| expiresAt | ISO 8601 | No | Expiration timestamp (for TEMP users) |
| expireDays | number | No | Days until expiration |
Response (201 Created):
{
"id": "clx0987654321",
"email": "newuser@example.com",
"name": "New User",
"phone": "+1234567890",
"role": "MAP_ADMIN",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"expiresAt": "2026-12-31T23:59:59.000Z",
"expireDays": 365,
"lastLoginAt": null,
"emailVerified": false,
"createdAt": "2026-02-11T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
Error Responses:
409 Conflict: Email already registered403 Forbidden: Non-admin trying to create user
PUT /api/users/:id
Update user. Admins can update any user and change role/status. Regular users can update their own profile (except role/status).
Request Headers:
Authorization: Bearer <access_token>
Content-Type: application/json
Path Parameters:
id(string): User ID to update
Request Body (Partial Update):
{
"name": "Updated Name",
"phone": "+0987654321",
"email": "newemail@example.com",
"password": "NewPass123",
"role": "INFLUENCE_ADMIN",
"status": "SUSPENDED"
}
All fields are optional (partial updates supported).
Response (200 OK):
{
"id": "clx1234567890",
"email": "newemail@example.com",
"name": "Updated Name",
"phone": "+0987654321",
"role": "INFLUENCE_ADMIN",
"status": "SUSPENDED",
...
}
Error Responses:
403 Forbidden: Non-admin trying to update another user or change role/status404 Not Found: User ID does not exist409 Conflict: Email already in use by another user
Permission Logic:
const isAdmin = ADMIN_ROLES.includes(req.user!.role);
const isSelf = req.user!.id === id;
if (!isAdmin && !isSelf) {
return res.status(403).json({ error: { message: 'Insufficient permissions', code: 'FORBIDDEN' } });
}
// Non-admins cannot change role or status
if (!isAdmin) {
delete req.body.role;
delete req.body.status;
}
Password Handling:
if (data.password) {
updateData.password = await bcrypt.hash(data.password, 12);
}
DELETE /api/users/:id
Delete user (admin only). Cascades to related records (refresh tokens, created campaigns, etc.).
Request Headers:
Authorization: Bearer <access_token>
Path Parameters:
id(string): User ID to delete
Example Request:
curl -X DELETE \
-H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/users/clx1234567890"
Response (204 No Content):
No response body.
Error Responses:
403 Forbidden: Non-admin trying to delete user404 Not Found: User ID does not exist
Cascading Deletes:
Deleting a user automatically deletes:
- Refresh tokens
- Created campaigns (if
createdByUserIdrelation) - Created locations (if
createdByUserIdrelation) - Campaign emails
- Responses
- Shift signups
Service Functions
usersService.findAll(filters)
Purpose: Paginated user listing with search and filters.
Parameters:
interface ListUsersInput {
page: number; // Default: 1
limit: number; // Default: 20, max: 100
search?: string; // Search email or name
role?: UserRole; // Filter by role
status?: UserStatus; // Filter by status
}
Returns:
{
users: User[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
Implementation:
const { page, limit, search, role, status } = filters;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {};
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
];
}
if (role) where.role = role;
if (status) where.status = status;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: userSelect,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return {
users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
usersService.findById(id)
Purpose: Get single user by ID.
Returns: User object or throws 404 error.
Security: Password excluded via select (never returned in API responses).
usersService.create(data)
Purpose: Create new user with hashed password.
Flow:
- Check if email already exists (
409if duplicate) - Hash password with bcrypt (12 salt rounds)
- Create user in database
- Return user (password excluded)
Expiration Handling:
const user = await prisma.user.create({
data: {
...data,
password: hashedPassword,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
},
select: userSelect,
});
usersService.update(id, data)
Purpose: Update existing user (partial updates supported).
Validation:
- Check user exists (
404if not found) - Check email uniqueness if changing email (
409if taken) - Hash password if provided
- Convert
expiresAtstring to Date
Email Change:
if (data.email && data.email !== existing.email) {
const emailTaken = await prisma.user.findUnique({ where: { email: data.email } });
if (emailTaken) {
throw new AppError(409, 'Email already in use', 'EMAIL_EXISTS');
}
}
usersService.delete(id)
Purpose: Delete user and cascade to related records.
Error Handling:
const existing = await prisma.user.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
}
await prisma.user.delete({ where: { id } });
Code Examples
Admin: List Users with Filters
import { api } from '@/lib/api';
const fetchUsers = async (page = 1, search = '', role = null, status = null) => {
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
});
if (search) params.append('search', search);
if (role) params.append('role', role);
if (status) params.append('status', status);
const { data } = await api.get(`/api/users?${params}`);
return data;
};
// Usage
const result = await fetchUsers(1, 'john', 'USER', 'ACTIVE');
console.log(`Total users: ${result.pagination.total}`);
console.log(`Users on page 1:`, result.users);
Admin: Create User
import { api } from '@/lib/api';
const createUser = async (userData) => {
const { data } = await api.post('/api/users', {
email: userData.email,
password: userData.password,
name: userData.name,
phone: userData.phone,
role: userData.role || 'USER',
status: userData.status || 'ACTIVE',
});
return data;
};
// Usage
const newUser = await createUser({
email: 'volunteer@example.com',
password: 'TempPass123',
name: 'Jane Volunteer',
role: 'USER',
});
console.log(`Created user: ${newUser.id}`);
Admin: Update User Role
import { api } from '@/lib/api';
const promoteToAdmin = async (userId: string, adminRole: string) => {
const { data } = await api.put(`/api/users/${userId}`, {
role: adminRole,
});
return data;
};
// Usage
const updatedUser = await promoteToAdmin('clx1234567890', 'MAP_ADMIN');
console.log(`User promoted to ${updatedUser.role}`);
User: Update Own Profile
import { api } from '@/lib/api';
const updateProfile = async (name: string, phone: string) => {
const { data } = await api.put(`/api/users/${currentUser.id}`, {
name,
phone,
});
return data;
};
// Usage (non-admin user updating self)
const updated = await updateProfile('Updated Name', '+1234567890');
console.log('Profile updated:', updated);
Admin: Suspend User
import { api } from '@/lib/api';
const suspendUser = async (userId: string) => {
const { data } = await api.put(`/api/users/${userId}`, {
status: 'SUSPENDED',
});
return data;
};
// Usage
const suspended = await suspendUser('clx1234567890');
console.log(`User ${suspended.email} suspended`);
Admin: Delete User
import { api } from '@/lib/api';
const deleteUser = async (userId: string) => {
await api.delete(`/api/users/${userId}`);
console.log(`User ${userId} deleted`);
};
// Usage
await deleteUser('clx1234567890');
Frontend Integration
The UsersPage component (admin/src/pages/UsersPage.tsx) provides a comprehensive UI for user management:
Features:
- Paginated table with role/status badges
- Search by email or name (300ms debounce)
- Filter dropdowns (role, status)
- Create user modal with form validation
- Edit user modal (pre-populated form)
- Delete confirmation modal
- Responsive design (mobile-friendly)
State Management:
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [filters, setFilters] = useState({ search: '', role: null, status: null });
API Integration:
const fetchUsers = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
});
if (filters.search) params.append('search', filters.search);
if (filters.role) params.append('role', filters.role);
if (filters.status) params.append('status', filters.status);
const { data } = await api.get(`/api/users?${params}`);
setUsers(data.users);
setPagination(data.pagination);
} catch (error) {
message.error('Failed to fetch users');
} finally {
setLoading(false);
}
};
Validation Schemas
Create User Schema
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().optional(),
phone: z.string().optional(),
role: z.nativeEnum(UserRole).optional(),
status: z.nativeEnum(UserStatus).optional(),
expiresAt: z.string().datetime().optional(),
expireDays: z.number().int().positive().optional(),
});
Note: Admin user creation has relaxed password requirements (8 chars vs. 12 for public registration).
Update User Schema
export const updateUserSchema = z.object({
email: z.string().email().optional(),
password: z.string().min(8).optional(),
name: z.string().optional(),
phone: z.string().optional(),
role: z.nativeEnum(UserRole).optional(),
status: z.nativeEnum(UserStatus).optional(),
expiresAt: z.string().datetime().nullable().optional(),
expireDays: z.number().int().positive().nullable().optional(),
});
List Users Schema
export const listUsersSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
role: z.nativeEnum(UserRole).optional(),
status: z.nativeEnum(UserStatus).optional(),
});
Security Considerations
Password Security
- Hashing: bcrypt with 12 salt rounds (admin creation) or enforced by registration schema
- Never Returned: Password excluded from all API responses via
selectclause - Updates: Re-hashed when changed
Permission Model
| Action | SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER |
|---|---|---|---|---|
| List users | ✅ | ✅ | ✅ | ❌ |
| View any user | ✅ | ✅ | ✅ | Own profile only |
| Create user | ✅ | ✅ | ✅ | ❌ |
| Update any user | ✅ | ✅ | ✅ | Own profile only |
| Change role/status | ✅ | ✅ | ✅ | ❌ |
| Delete user | ✅ | ✅ | ✅ | ❌ |
Email Uniqueness
- Enforced at database level (
@uniqueconstraint) - Checked before creation (
409 Conflict) - Checked before email change (
409 Conflict)
Cascading Deletes
Deleting a user automatically deletes related records via Prisma onDelete: Cascade:
- RefreshTokens
- Created campaigns
- Created locations
- Campaign emails
- Responses
- Shift signups
Related Documentation
- Auth Module - Authentication and JWT tokens
- Middleware: RBAC - Role-based access control
- Frontend: UsersPage - User management UI
- Database: User Model - User schema documentation
- API Reference: Users - Complete endpoint reference
- User Guide: Admin - Managing users guide