# 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: ```prisma 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 ``` **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:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/users?page=1&limit=20&search=john&role=USER&status=ACTIVE" ``` **Response (200 OK):** ```json { "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:** ```typescript 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:** ```typescript 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 ``` **Path Parameters:** - `id` (string): User ID **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/users/clx1234567890" ``` **Response (200 OK):** ```json { "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 user - `404 Not Found`: User ID does not exist **Permission Logic:** ```typescript 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 Content-Type: application/json ``` **Request Body:** ```json { "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 | |-------|------|----------|-------------| | email | 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):** ```json { "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 registered - `403 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 Content-Type: application/json ``` **Path Parameters:** - `id` (string): User ID to update **Request Body (Partial Update):** ```json { "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):** ```json { "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/status - `404 Not Found`: User ID does not exist - `409 Conflict`: Email already in use by another user **Permission Logic:** ```typescript 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:** ```typescript 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 ``` **Path Parameters:** - `id` (string): User ID to delete **Example Request:** ```bash curl -X DELETE \ -H "Authorization: Bearer " \ "http://api.cmlite.org/api/users/clx1234567890" ``` **Response (204 No Content):** No response body. **Error Responses:** - `403 Forbidden`: Non-admin trying to delete user - `404 Not Found`: User ID does not exist **Cascading Deletes:** Deleting a user automatically deletes: - Refresh tokens - Created campaigns (if `createdByUserId` relation) - Created locations (if `createdByUserId` relation) - Campaign emails - Responses - Shift signups ## Service Functions ### usersService.findAll(filters) **Purpose:** Paginated user listing with search and filters. **Parameters:** ```typescript 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:** ```typescript { users: User[]; pagination: { page: number; limit: number; total: number; totalPages: number; }; } ``` **Implementation:** ```typescript 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:** 1. Check if email already exists (`409` if duplicate) 2. Hash password with bcrypt (12 salt rounds) 3. Create user in database 4. Return user (password excluded) **Expiration Handling:** ```typescript 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 (`404` if not found) - Check email uniqueness if changing email (`409` if taken) - Hash password if provided - Convert `expiresAt` string to Date **Email Change:** ```typescript 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:** ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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:** ```typescript const [users, setUsers] = useState([]); const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 }); const [filters, setFilters] = useState({ search: '', role: null, status: null }); ``` **API Integration:** ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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 `select` clause - **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 (`@unique` constraint) - 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](/v2/backend/modules/auth.md) - Authentication and JWT tokens - [Middleware: RBAC](/v2/backend/middleware/rbac.md) - Role-based access control - [Frontend: UsersPage](/v2/frontend/pages/admin/users-page.md) - User management UI - [Database: User Model](/v2/database/models/auth.md) - User schema documentation - [API Reference: Users](/v2/api-reference/users.md) - Complete endpoint reference - [User Guide: Admin](/v2/user-guides/admin-guide.md) - Managing users guide