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 user
  • 404 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
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):

{
  "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 <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/status
  • 404 Not Found: User ID does not exist
  • 409 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 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:

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:

  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:

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:

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 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