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:
forcePasswordResetflag forces user to reset password on next login (admin-set for security)lastLoginAttracks login history for activity monitoringstatushas 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):
| Column | Content | Notes |
|---|---|---|
| Name | fullName | From User.fullName |
| User.email | ||
| Role | role | ADMIN, HR, MANAGER, SMO (badge) |
| Status | status | ACTIVE (green), DISABLED (red), PENDING_INVITE (yellow) |
| Last Login | lastLoginAt | "2 hours ago" or "Never" |
| Created | createdAt | Date 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 cookie403 Forbidden- User is not ADMIN500 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 JWT403 Forbidden- Not ADMIN404 Not Found- User doesn't exist500 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
isActiveis false, updatesstatusto DISABLED - If
isActiveis true, updatesstatusto 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
forcePasswordResetchanged true:PASSWORD_RESET_REQUIRED_SET - If
forcePasswordResetchanged 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 guardrail401 Unauthorized- No valid JWT403 Forbidden- Not ADMIN404 Not Found- User doesn't exist500 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_CHANGEDwith{ oldRole: "HR", newRole: "MANAGER" }
Response (200 OK):
{
"id": "user-001",
"role": "MANAGER"
}
Error Responses:
400 Bad Request- Invalid role or self-modify guardrail401 Unauthorized- No valid JWT403 Forbidden- Not ADMIN404 Not Found- User doesn't exist500 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:
- Verify user status is PENDING_INVITE (else 400)
- Generate new AuthToken with type=INVITE, 7-day expiry
- Generate invite link:
/setup?token=<token> - Log audit event:
INVITE_RESENT - Return inviteLink (for copying/sending manually)
- (Future: send email with link)
Error Responses:
400 Bad Request- User is not PENDING_INVITE401 Unauthorized- No valid JWT403 Forbidden- Not ADMIN404 Not Found- User doesn't exist500 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 param401 Unauthorized- No valid JWT403 Forbidden- Not ADMIN500 Server Error- Database error
F. RBAC & Authorization
Access Control Matrix:
| Endpoint | Role Required | Notes |
|---|---|---|
| GET /api/users | ADMIN | List all users |
| GET /api/users/:id | ADMIN | View user detail |
| PATCH /api/users/:id | ADMIN | Update settings (with guardrails) |
| PUT /api/users/:id/role | ADMIN | Change role (with guardrails) |
| POST /api/users/:id/resend-setup | ADMIN | Resend invite (status check) |
| GET /api/admin/audit | ADMIN | View 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):
| Action | Guardrail | Error |
|---|---|---|
| PATCH /api/users/:id to disable | Cannot disable own account | 400 |
| PUT /api/users/:id/role to non-ADMIN | Cannot remove own ADMIN role | 400 |
| POST /api/users/:id/resend-setup | User must be PENDING_INVITE | 400 |
G. Audit Events
Events Logged (all W4 operations):
| Event Type | Trigger | Details JSON |
|---|---|---|
| USER_DISABLED | Admin sets isActive=false | { wasActive: true } |
| USER_ENABLED | Admin sets isActive=true | { wasActive: false } |
| PASSWORD_RESET_REQUIRED_SET | Admin sets forcePasswordReset=true | {} |
| PASSWORD_RESET_REQUIRED_CLEARED | Admin sets forcePasswordReset=false | {} |
| USER_ROLE_CHANGED | Admin calls PUT /api/users/:id/role | { oldRole: "HR", newRole: "MANAGER" } |
| INVITE_RESENT | Admin 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/usersdirectly - 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)
- W5+: Implement remaining wireframes (Candidate intake, etc.)
- Password Reset: Implement actual password reset flow (set up page, token verification)
- Notifications: Send real emails for invites (via SMTP)
- Audit Export: Allow admins to export audit logs to CSV
- Bulk Actions: Enable role change/disable for multiple users at once
- Invite Expiry: Show countdown timer for pending invites
- Session Management: Admin view of active sessions per user
- Compliance: Add data retention policies and log archival