Handover workspace

ERS, Todo, OfferReview, and Docu in one view

Imported from live server docs, code structure, and deployment notes.

Apr 3, 2026, 12:38 PM

OfferReview

W4: Admin Users & Roles Management - Implementation Guide

W4 implements a complete admin interface for managing user accounts and permissions. This wireframe provides admins the ability to:

docs/W4-ADMIN-USERS-IMPLEMENTATION.md

Updated Feb 19, 2026, 6:59 AM

Codex 5.3 Refactor Note: Canonical refactor plan: docs/CODEX-5.3-REFACTOR-PLAN.md. This document is retained for historical and implementation context during the refactor.

W4: Admin Users & Roles Management - Implementation Guide

A. Summary

W4 implements a complete admin interface for managing user accounts and permissions. This wireframe provides admins the ability to:

  • Search and filter users by name, email, role, status, or last login activity
  • View user details including profile info, role, account status, and recent activity
  • Change user roles between HR, MANAGER, SMO, and ADMIN (with guardrails to prevent self-demotion)
  • Control account access by enabling/disabling accounts (with guardrails to prevent self-lockout)
  • Force password resets for security compliance
  • Resend setup invites for users pending initialization
  • Review audit logs of recent user activity changes

This is a critical admin utility supporting user lifecycle management and security governance.

B. Routes & Navigation

Data Flow

GET /api/users (search, filter, paginate)
  ├─ Admin users list with status, role, last login
  └─ On row select:
      ├─ GET /api/users/:id (load detail)
      ├─ PATCH /api/users/:id (update settings)
      ├─ PUT /api/users/:id/role (change role)
      ├─ POST /api/users/:id/resend-setup (resend invite)
      └─ GET /api/admin/audit?userId=:id (preview logs)

URL Pattern

  • Page: /admin/users (requires ADMIN role)
  • Parent Layout: /admin (stub exists)
  • Redirect: All routes require valid JWT in httpOnly cookie (enforced by RBAC middleware)

C. Data Model Changes

User Schema Updates (Prisma)

Modified Fields:

model User {
  // Existing
  id                    String   @id @default(cuid())
  email                 String   @unique
  fullName              String?
  passwordHash          String
  role                  Role     @default(ADMIN)
  status                UserStatus @default(ACTIVE)
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt
  
  // NEW: Added in W4
  forcePasswordReset    Boolean  @default(false)
  lastLoginAt           DateTime?
}

enum UserStatus {
  ACTIVE           // Active account, can log in
  DISABLED         // Admin-disabled account, cannot log in
  PENDING_INVITE   // Created but not yet activated, has pending invite token
}

Key Points:

  • forcePasswordReset flag forces user to reset password on next login (admin-set for security)
  • lastLoginAt tracks login history for activity monitoring
  • status has 3 states: ACTIVE (working), DISABLED (locked), PENDING_INVITE (awaiting activation)

Audit Events Added

enum AuditEventType {
  // ... existing events from W1-W3
  USER_DISABLED                       // Admin disabled account
  USER_ENABLED                        // Admin enabled account
  PASSWORD_RESET_REQUIRED_SET         // Admin forced password reset
  PASSWORD_RESET_REQUIRED_CLEARED     // Admin cleared password reset requirement
  INVITE_RESENT                       // Admin resent setup invite
}

D. UI Components

File Structure

src/app/admin/users/
├── page.tsx                          (Main page orchestrator)
├── _components/
│   ├── UsersTable.tsx               (Left panel: search/filter table)
│   └── UserDetail.tsx               (Right panel: detail + controls)

1. Main Page (page.tsx)

Component Type: Client Component ('use client')

Props: None (all state managed internally)

State Management:

- users: User[] - List from API
- selectedId: string | null - Currently selected user
- selectedUser: UserDetail | null - Full detail of selected user
- filters: { q: string; role: string; status: string; lastLoginRange: string }
- isLoading: boolean - Table loading state
- isProcessing: boolean - API operation in progress

Key Features:

  • Dual panels: Left panel (table) + Right panel (detail)
  • Real-time filtering: Updates users list on filter change
  • Detail loading: Loads full user when row selected
  • State sync: Clears selection if user removed from current filter
  • Toast notifications: Success/error feedback for operations

Handlers:

handleUpdateSettings(changes) → PATCH /api/users/:id
handleRoleChange(role) → PUT /api/users/:id/role
handleResendSetup() → POST /api/users/:id/resend-setup

Layout:

┌─────────────────────────────────────────────────────────┐
│ Header: "Users & Roles" | "Manage user accounts..."     │
├──────────────────┬────────────────────────────────────┤
│  Left Panel      │  Right Panel                       │
│ (1/3 width)      │  (2/3 width)                       │
│                  │                                     │
│ UsersTable       │ UserDetail OR                      │
│ - Search box     │ "Select a user to view details"   │
│ - Filter chips   │                                     │
│ - Users list     │                                     │
│   (clickable)    │                                     │
└──────────────────┴────────────────────────────────────┘

2. UsersTable Component

Props:

interface Props {
  users: User[]
  selectedId: string | null
  onSelect: (id: string) => void
  onSearch: (q: string) => void
  onRoleFilter: (role: string) => void
  onStatusFilter: (status: string) => void
  onLastLoginFilter: (range: string) => void
  isLoading: boolean
}

Columns (table):

ColumnContentNotes
NamefullNameFrom User.fullName
EmailemailUser.email
RoleroleADMIN, HR, MANAGER, SMO (badge)
StatusstatusACTIVE (green), DISABLED (red), PENDING_INVITE (yellow)
Last LoginlastLoginAt"2 hours ago" or "Never"
CreatedcreatedAtDate format: "Jan 15, 2025"

Search:

  • Field: "Search users..."
  • Searches by: name OR email (debounced 300ms)
  • Callback: onSearch(q) with query text

Filters (chip buttons):

Role:
  ☐ All  ☐ ADMIN  ☐ HR  ☐ MANAGER  ☐ SMO

Status:
  ☐ All  ☐ ACTIVE  ☐ DISABLED  ☐ PENDING_INVITE

Last Login:
  ☐ All  ☐ Last 7 days  ☐ Last 30 days  ☐ Never

Interactions:

  • Click row → select (highlight + load detail)
  • Type search → debounce + filter
  • Click filter chip → toggle + update
  • Click selected row again → deselect (optional)

3. UserDetail Component

Props:

interface Props {
  user: UserDetail
  currentUserId: string // Selected user ID for operations
  onUpdateSettings: (changes) => Promise<void>
  onRoleChange: (role: Role) => Promise<void>
  onResendSetup: () => Promise<string>
  isLoading: boolean
}

Sections:

3a. Header

[User Avatar/Icon] [Email Address]
[Full Name]

3b. Profile Section (read-only)

Email:       user@example.com
Role:        ADMIN / HR / MANAGER / SMO
Status:      ACTIVE / DISABLED / PENDING_INVITE (badge)
Created:     Jan 15, 2025 at 3:45 PM
Last Login:  2 hours ago / Never

3c. Access Controls Section

□ Account Active            [Toggle ON/OFF]
□ Force Password Reset      [Toggle ON/OFF]

Role:  [Dropdown: ADMIN / HR / MANAGER / SMO]

Guardrails:

  • Cannot disable own account (error: "You cannot disable your own account")
  • Cannot remove own ADMIN role (error: "You cannot remove your own admin role")
  • All changes require confirmation modal

3d. Setup Section (visible only if status === PENDING_INVITE)

[Resend Setup Button]
Sends new invite link to: user@example.com

3e. Recent Activity Section

Recent Changes (Last 10 events)
─────────────────────────────────
Jan 15, 3:45 PM  │  USER_ROLE_CHANGED  │  HR → MANAGER (by admin@...)
Jan 15, 3:40 PM  │  USER_ENABLED       │  Account activated (by admin@...)
[View Full Audit Log] →

Modals (confirmation dialogs):

Modal 1: Confirm Role Change

Title: Change User Role?
Body: This will change the user's access permissions.
      Are you sure you want to change their role to MANAGER?
Buttons: [Cancel] [Confirm]

Modal 2: Confirm Account Disable

Title: Disable Account?
Body: The user will not be able to log in.
      Continue with disabling?
Buttons: [Cancel] [Disable]

Modal 3: Force Password Reset Confirmation

Title: Force Password Reset?
Body: User will be required to reset password on next login.
      Continue?
Buttons: [Cancel] [Confirm]

Modal 4: Resend Setup Confirmation

Title: Resend Setup Email?
Body: A new invitation link will be sent to user@example.com
      Existing links will remain valid.
      Continue?
Buttons: [Cancel] [Resend]
Result: Shows invite link after success (can be copied)

E. API Endpoints

1. GET /api/users (Admin List)

RBAC: Requires ADMIN role

Query Params:

{
  q?: string              // Search query (name or email)
  role?: string           // Filter: ADMIN | HR | MANAGER | SMO
  status?: string         // Filter: ACTIVE | DISABLED | PENDING_INVITE
  lastLoginRange?: string // Filter: 7days | 30days | never
  page?: number           // Pagination: default 1
  pageSize?: number       // Page size: default 20
}

Response (200 OK):

{
  "users": [
    {
      "id": "user-001",
      "email": "john@example.com",
      "fullName": "John Doe",
      "role": "HR",
      "status": "ACTIVE",
      "lastLoginAt": "2025-01-15T15:30:00Z",
      "createdAt": "2025-01-10T08:00:00Z"
    }
  ],
  "total": 42,
  "page": 1,
  "pageSize": 20
}

Error Responses:

  • 401 Unauthorized - No valid JWT cookie
  • 403 Forbidden - User is not ADMIN
  • 500 Server Error - Database error

2. GET /api/users/[id] (User Detail)

RBAC: Requires ADMIN role

URL Params: id (user UUID)

Response (200 OK):

{
  "id": "user-001",
  "email": "john@example.com",
  "fullName": "John Doe",
  "role": "HR",
  "status": "ACTIVE",
  "forcePasswordReset": false,
  "lastLoginAt": "2025-01-15T15:30:00Z",
  "createdAt": "2025-01-10T08:00:00Z",
  "updatedAt": "2025-01-15T14:20:00Z"
}

Error Responses:

  • 401 Unauthorized - No valid JWT
  • 403 Forbidden - Not ADMIN
  • 404 Not Found - User doesn't exist
  • 500 Server Error - Database error

3. PATCH /api/users/[id] (Update Settings)

RBAC: Requires ADMIN role

Request Body:

{
  "isActive": true,
  "forcePasswordReset": true
}

Validation:

  • isActive: boolean (optional)
  • forcePasswordReset: boolean (optional)
  • If isActive is false, updates status to DISABLED
  • If isActive is true, updates status to ACTIVE

Guardrails:

  • Cannot disable own account → 400 "You cannot disable your own account"
  • Cannot change own forcePasswordReset → (implementation choice: allow or block)

Audit Events (logged):

  • If status changed from ACTIVE → DISABLED: USER_DISABLED
  • If status changed from DISABLED → ACTIVE: USER_ENABLED
  • If forcePasswordReset changed true: PASSWORD_RESET_REQUIRED_SET
  • If forcePasswordReset changed false: PASSWORD_RESET_REQUIRED_CLEARED

Response (200 OK):

{
  "id": "user-001",
  "email": "john@example.com",
  "status": "DISABLED",
  "forcePasswordReset": true
}

Error Responses:

  • 400 Bad Request - Validation failed or self-modify guardrail
  • 401 Unauthorized - No valid JWT
  • 403 Forbidden - Not ADMIN
  • 404 Not Found - User doesn't exist
  • 500 Server Error - Database error

4. PUT /api/users/[id]/role (Change Role)

RBAC: Requires ADMIN role

Request Body:

{
  "role": "MANAGER"  // ADMIN | HR | MANAGER | SMO
}

Validation:

  • role: enum (required, must be valid Role)

Guardrails:

  • Cannot change own role to non-ADMIN → 400 "You cannot remove your own admin role"

Audit Events (logged):

  • USER_ROLE_CHANGED with { oldRole: "HR", newRole: "MANAGER" }

Response (200 OK):

{
  "id": "user-001",
  "role": "MANAGER"
}

Error Responses:

  • 400 Bad Request - Invalid role or self-modify guardrail
  • 401 Unauthorized - No valid JWT
  • 403 Forbidden - Not ADMIN
  • 404 Not Found - User doesn't exist
  • 500 Server Error - Database error

5. POST /api/users/[id]/resend-setup (Resend Invite)

RBAC: Requires ADMIN role

Precondition: User status must be PENDING_INVITE

Response (200 OK):

{
  "inviteLink": "https://app.example.com/setup?token=abc123...",
  "email": "john@example.com"
}

Logic:

  1. Verify user status is PENDING_INVITE (else 400)
  2. Generate new AuthToken with type=INVITE, 7-day expiry
  3. Generate invite link: /setup?token=<token>
  4. Log audit event: INVITE_RESENT
  5. Return inviteLink (for copying/sending manually)
  6. (Future: send email with link)

Error Responses:

  • 400 Bad Request - User is not PENDING_INVITE
  • 401 Unauthorized - No valid JWT
  • 403 Forbidden - Not ADMIN
  • 404 Not Found - User doesn't exist
  • 500 Server Error - Database error

6. GET /api/admin/audit (Audit Preview)

RBAC: Requires ADMIN role

Query Params:

{
  userId: string   // User ID to preview (required)
  limit?: number   // Default 10, max 50
}

Response (200 OK):

[
  {
    "id": "audit-001",
    "eventType": "USER_ROLE_CHANGED",
    "createdAt": "2025-01-15T15:30:00Z",
    "details": {
      "oldRole": "HR",
      "newRole": "MANAGER"
    }
  },
  {
    "id": "audit-002",
    "eventType": "USER_ENABLED",
    "createdAt": "2025-01-15T15:20:00Z",
    "details": {}
  }
]

Error Responses:

  • 400 Bad Request - Missing userId param
  • 401 Unauthorized - No valid JWT
  • 403 Forbidden - Not ADMIN
  • 500 Server Error - Database error

F. RBAC & Authorization

Access Control Matrix:

EndpointRole RequiredNotes
GET /api/usersADMINList all users
GET /api/users/:idADMINView user detail
PATCH /api/users/:idADMINUpdate settings (with guardrails)
PUT /api/users/:id/roleADMINChange role (with guardrails)
POST /api/users/:id/resend-setupADMINResend invite (status check)
GET /api/admin/auditADMINView audit logs

Implementation Pattern:

// In each route handler
const adminPayload = await requireRole('ADMIN');
// adminPayload = { userId, email, role }
// Throws 403 if not ADMIN

Guardrails (application logic, not RBAC):

ActionGuardrailError
PATCH /api/users/:id to disableCannot disable own account400
PUT /api/users/:id/role to non-ADMINCannot remove own ADMIN role400
POST /api/users/:id/resend-setupUser must be PENDING_INVITE400

G. Audit Events

Events Logged (all W4 operations):

Event TypeTriggerDetails JSON
USER_DISABLEDAdmin sets isActive=false{ wasActive: true }
USER_ENABLEDAdmin sets isActive=true{ wasActive: false }
PASSWORD_RESET_REQUIRED_SETAdmin sets forcePasswordReset=true{}
PASSWORD_RESET_REQUIRED_CLEAREDAdmin sets forcePasswordReset=false{}
USER_ROLE_CHANGEDAdmin calls PUT /api/users/:id/role{ oldRole: "HR", newRole: "MANAGER" }
INVITE_RESENTAdmin calls POST /api/users/:id/resend-setup{ newTokenId: "..." }

Audit Log Retrieval:

  • GET /api/admin/audit?userId=:id - Returns last 10 events for user
  • Full audit page available for deeper investigation
  • Events are append-only (immutable)

H. Testing Checklist

Prerequisites

  • Database migrated: npx prisma migrate dev --name add_user_admin_fields
  • Seed data loaded: npx prisma db seed (creates admin@example.com)
  • Dev server running: npm run dev
  • Logged in as admin (JWT in httpOnly cookie)

Manual Tests

Test 1: User List & Filters

  • Navigate to /admin/users
  • Verify 4+ test users visible in table
  • Search by name: "John" → filters results
  • Search by email: "example.com" → filters results
  • Filter by Role: HR → shows only HR users
  • Filter by Status: PENDING_INVITE → shows pending users
  • Filter by Last Login: "Never" → shows users without login
  • Multiple filters work together

Test 2: User Detail View

  • Click user row → detail panel loads on right
  • Profile section shows: email, name, role, status, dates
  • Check recent activity shows last 5+ events
  • Click another user → detail updates
  • Click same user → deselects (or stays selected)

Test 3: Role Change

  • Select non-ADMIN user (e.g., HR user)
  • Change role: ADMIN → MANAGER → SMO → HR → ADMIN
  • Each change shows confirmation modal
  • After confirm, role updates in table + detail
  • Toast shows "Role updated successfully"
  • Audit log shows USER_ROLE_CHANGED event
  • Try to change own ADMIN role → error modal "Cannot remove your own admin role"

Test 4: Account Disable/Enable

  • Select ACTIVE user
  • Toggle "Account Active" OFF
  • Confirmation modal appears
  • After confirm, status changes to DISABLED
  • Status badge changes to red
  • Audit log shows USER_DISABLED event
  • Toggle back ON → ACTIVE, USER_ENABLED event
  • Try to disable own account → error "Cannot disable your own account"

Test 5: Force Password Reset

  • Select any user
  • Toggle "Force Password Reset" ON
  • Confirmation modal appears
  • After confirm, toggles to ON
  • Audit log shows PASSWORD_RESET_REQUIRED_SET
  • Toggle OFF → PASSWORD_RESET_REQUIRED_CLEARED event
  • (Future: user sees password reset prompt on login)

Test 6: Resend Setup

  • Create a PENDING_INVITE user (via W3 approval flow)
  • In W4, find and select the PENDING_INVITE user
  • "Resend Setup" button visible in setup section
  • Click button → confirmation modal
  • After confirm, shows invite link
  • Toast shows "Setup email sent"
  • Audit log shows INVITE_RESENT event
  • Invite link is valid and can be copied

Test 7: Pagination & Performance

  • With 20+ users, pagination works
  • Page 1 shows users 1-20
  • Page 2 shows users 21-40
  • Search/filter updates page numbering

Test 8: Non-Admin Access

  • Log out, log in as non-admin (HR, MANAGER, SMO)
  • Try to access /admin/users directly
  • Should be redirected or show 403 error
  • Verify API returns 403 for GET /api/users

Test 9: API Error Handling

  • Disable network (DevTools) → "Failed to load users" toast
  • Modify request to invalid ID → "Failed to load user details"
  • Verify all error cases handled with user-friendly messages

Test 10: Toast Notifications

  • Success: Role change → green toast "Role updated successfully"
  • Success: Resend setup → "Setup email sent"
  • Error: API fails → red toast with error message
  • Toast auto-dismisses after 5 seconds

Browser DevTools Checks

  • Check httpOnly cookie is set (auth_token)
  • JWT token is valid (not expired)
  • API requests include cookie (Authorization header optional)
  • No console errors during operations
  • Network requests to /api/users* are 200 OK
  • Confirm CORS headers if cross-origin

Database Verification

-- Check users table updated
SELECT id, email, role, status, forcePasswordReset, lastLoginAt 
FROM "User" LIMIT 5;

-- Check audit events logged
SELECT eventType, userId, details, "createdAt" 
FROM "AuditLog" 
WHERE userId = 'user-001' 
ORDER BY "createdAt" DESC 
LIMIT 10;

Next Steps (Post-W4)

  1. W5+: Implement remaining wireframes (Candidate intake, etc.)
  2. Password Reset: Implement actual password reset flow (set up page, token verification)
  3. Notifications: Send real emails for invites (via SMTP)
  4. Audit Export: Allow admins to export audit logs to CSV
  5. Bulk Actions: Enable role change/disable for multiple users at once
  6. Invite Expiry: Show countdown timer for pending invites
  7. Session Management: Admin view of active sessions per user
  8. Compliance: Add data retention policies and log archival