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.
W3 Implementation: Admin Access Requests
A. Summary
Implemented a complete admin interface for reviewing, approving, and rejecting access requests. Features include 2-panel layout (master list + detail), search/filter capabilities, approval workflow with user creation, rejection workflow, audit logging, and invite token generation.
B. Routes Implemented
Pages
- GET /admin/access-requests – Admin dashboard with 2-panel layout (protected, ADMIN only)
API Endpoints
- GET /api/access-requests – List access requests with filters (ADMIN only)
- Query params: status, role, q (search), page
- GET /api/access-requests/[id] – Get request details (ADMIN only)
- POST /api/access-requests/[id]/approve – Approve request & create user (ADMIN only)
- POST /api/access-requests/[id]/reject – Reject request (ADMIN only)
- POST /api/access-requests – Public create request (from W2, kept unchanged)
C. Data Model Changes
Prisma Schema Updates
Updated Enums:
UserStatus– added PENDING_INVITE (for users awaiting first login)AuditEventType– added ACCESS_REQUEST_APPROVED, ACCESS_REQUEST_REJECTED, ROLE_ASSIGNED, USER_ENABLED
Updated Models:
User– added fullName (nullable), relations for decidedRequests and authTokensAccessRequest– added DecidedBy relation (already existed, updated for W3)
New Enum:
AuthTokenType– INVITE, RESET_PASSWORD
New Model:
model AuthToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
type AuthTokenType
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([tokenHash])
@@index([type])
}
Migration Steps
cd /Users/rezafahmi/projectweb-nextjs
# Create migration (combines User updates, AuthToken, and enum updates)
npx prisma migrate dev --name add_admin_access_request_actions
# Verify schema
npx prisma studio
D. UI Components Created
Main Page
- src/app/admin/access-requests/page.tsx – Main dashboard with 2-panel layout, state management, API integration
Sub-components
- src/app/admin/access-requests/_components/RequestTable.tsx – Left panel: searchable/filterable table of requests
- src/app/admin/access-requests/_components/RequestDetail.tsx – Right panel: request details + approve/reject actions
- src/components/Toast.tsx – Toast notification system
Features
- 2-panel responsive layout
- Search by name/email/requestCode
- Filter by status (PENDING/APPROVED/REJECTED) and role
- Row click to select and load detail
- Approve form with role assignment
- Reject form with optional reason
- Confirmation modals before action
- Toast notifications for success/error
- Read-only view for already-handled requests
E. API Logic Created
Files
- src/app/api/access-requests/route.ts – Updated GET (admin list) + POST (public create from W2)
- src/app/api/access-requests/[id]/route.ts – GET request detail
- src/app/api/access-requests/[id]/approve/route.ts – POST approve endpoint
- src/app/api/access-requests/[id]/reject/route.ts – POST reject endpoint
- src/lib/auth/rbac.ts – RBAC middleware (requireAuth, requireRole, requirePermission)
- src/lib/auth/tokens.ts – Invite token generation/verification
GET /api/access-requests (Admin List)
Query params:
status– filter by PENDING/APPROVED/REJECTED (default: PENDING)role– filter by HR/MANAGER/SMO/ADMINq– search name/email/requestCodepage– page number (default: 1)
Response:
{
"requests": [
{
"id": "...",
"requestCode": "REQ-2026-12345",
"fullName": "...",
"workEmail": "...",
"department": "...",
"requestedRole": "HR",
"reason": "...",
"managerEmail": "...",
"status": "PENDING",
"decidedByUserId": null,
"decidedAt": null,
"decisionNote": null,
"createdAt": "...",
"updatedAt": "..."
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 45,
"pages": 3
}
}
GET /api/access-requests/[id] (Detail)
Returns full request object with decidedBy relation populated.
POST /api/access-requests/[id]/approve
Body:
{
"assignedRole": "HR|MANAGER|SMO|ADMIN",
"forcePasswordSetup": true
}
Behavior:
- Verify request exists and status = PENDING (else 409)
- Check/create user:
- If user doesn't exist: create with email, fullName, role, status=PENDING_INVITE
- If user exists but disabled: enable to PENDING_INVITE
- Update user role if different
- Generate invite token (7-day expiry)
- Update AccessRequest: status=APPROVED, decidedBy, decidedAt
- Log audit events: USER_CREATED (if new), ROLE_ASSIGNED, ACCESS_REQUEST_APPROVED
- Return 200 with inviteLink + userId
Response:
{
"status": "APPROVED",
"inviteLink": "http://localhost:3000/auth/accept-invite?token=...",
"userId": "..."
}
POST /api/access-requests/[id]/reject
Body:
{
"reason": "string (optional)"
}
Behavior:
- Verify request exists and status = PENDING (else 409)
- Update AccessRequest: status=REJECTED, decidedBy, decidedAt, decisionNote
- Log audit event: ACCESS_REQUEST_REJECTED
- Return 200
F. RBAC Checks
Access Control
/admin/access-requestspage: ADMIN only (enforced client-side via redirect, will add server-side middleware in future)GET /api/access-requests(admin list): ADMIN only (server-side via requireRole)GET /api/access-requests/[id]: ADMIN only (server-side via requireRole)POST /api/access-requests/[id]/approve: ADMIN only (server-side via requireRole)POST /api/access-requests/[id]/reject: ADMIN only (server-side via requireRole)POST /api/access-requests(public create): Public (no auth required)
Implementation
- Created
requireRole('ADMIN')helper in src/lib/auth/rbac.ts - All admin endpoints call this before processing
- Returns 403 Forbidden if user is not ADMIN
G. Audit Events
ACCESS_REQUEST_APPROVED
{
"eventType": "ACCESS_REQUEST_APPROVED",
"userId": "admin_user_id",
"details": {
"accessRequestId": "req_id",
"requestCode": "REQ-2026-12345",
"workEmail": "user@example.com",
"assignedRole": "HR",
"userId": "new_user_id"
}
}
ACCESS_REQUEST_REJECTED
{
"eventType": "ACCESS_REQUEST_REJECTED",
"userId": "admin_user_id",
"details": {
"accessRequestId": "req_id",
"requestCode": "REQ-2026-12345",
"workEmail": "user@example.com",
"reason": "Does not meet requirements"
}
}
USER_CREATED (if user didn't exist)
{
"eventType": "USER_CREATED",
"userId": "admin_user_id",
"details": {
"userId": "new_user_id",
"email": "user@example.com",
"fullName": "User Name",
"status": "PENDING_INVITE",
"accessRequestId": "req_id"
}
}
ROLE_ASSIGNED
{
"eventType": "ROLE_ASSIGNED",
"userId": "admin_user_id",
"details": {
"userId": "user_id",
"email": "user@example.com",
"newRole": "HR",
"accessRequestId": "req_id"
}
}
USER_ENABLED (if user was disabled)
{
"eventType": "USER_ENABLED",
"userId": "admin_user_id",
"details": {
"userId": "user_id",
"email": "user@example.com",
"previousStatus": "DISABLED",
"newStatus": "PENDING_INVITE"
}
}
H. Test Checklist
Setup
- Run migration:
npx prisma migrate dev --name add_admin_access_request_actions - Update seed to create admin user (already exists from W1)
- Create test access requests via W2 (/request-access)
- Run
npm run dev
Authentication & Authorization
- Visit
/admin/access-requestsas non-admin user → should fail (will add redirect in future) - Login as admin@example.com (from seed)
- Visit
/admin/access-requests→ loads page successfully
Request List (Left Panel)
- Page loads with PENDING requests by default
- Search by name, email, or requestCode → filters correctly
- Filter by status (APPROVED/REJECTED/All) → filters correctly
- Filter by role (HR/MANAGER/SMO/ADMIN) → filters correctly
- Click request row → loads detail on right panel
- Request row highlights when selected
Request Detail (Right Panel)
- Shows all request fields (fullName, workEmail, department, reason, etc.)
- Shows requestCode and submission timestamp
- Shows role dropdown (default = requestedRole)
- Approve button and Reject button visible
- For already-handled requests: shows "Read-only" message + status/decidedBy/decidedAt/decisionNote
Approve Workflow
- Select PENDING request
- Change assigned role (if different from requested)
- Click "Approve & Create User"
- Confirmation modal appears
- After confirmation:
- Toast shows "Request approved"
- Invite link is copied to clipboard
- Request moves to APPROVED status
- Right panel switches to read-only mode
- Check database:
- AccessRequest.status = APPROVED
- AccessRequest.decidedByUserId = admin id
- AccessRequest.decidedAt = now
- User created or updated with PENDING_INVITE status
- AuditLog entries for USER_CREATED (if new), ROLE_ASSIGNED, ACCESS_REQUEST_APPROVED
Reject Workflow
- Select PENDING request
- Click "Reject"
- Rejection form shows with reason textarea
- Enter optional reason
- Click "Confirm Rejection"
- Confirmation modal appears
- After confirmation:
- Toast shows "Request rejected"
- Request moves to REJECTED status
- Right panel switches to read-only mode
- Check database:
- AccessRequest.status = REJECTED
- AccessRequest.decidedByUserId = admin id
- AccessRequest.decidedAt = now
- AccessRequest.decisionNote = entered reason (or null)
- AuditLog entry for ACCESS_REQUEST_REJECTED
Error Cases
- Try to approve already-approved request → 409 error, toast shows message
- Try to approve with invalid role → 400 error with validation details
- Approve request for email that already has active user → user updated, not duplicated
- Check pagination works (20 items per page)
Invite Token Verification
# In database:
npx prisma studio
# After approving:
# - Check AuthToken table
# - Verify tokenHash is present (hashed version of token sent in inviteLink)
# - Verify type = INVITE
# - Verify expiresAt is 7 days from now
# - Verify usedAt is null (used only after user accepts invite)
API Direct Testing (curl/Postman)
# List admin requests (as admin)
curl -H "Authorization: Bearer <admin_token>" \
http://localhost:3000/api/access-requests?status=PENDING
# Get request detail
curl -H "Authorization: Bearer <admin_token>" \
http://localhost:3000/api/access-requests/[request_id]
# Approve request
curl -X POST -H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"assignedRole":"HR","forcePasswordSetup":true}' \
http://localhost:3000/api/access-requests/[request_id]/approve
# Reject request
curl -X POST -H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"reason":"Does not meet requirements"}' \
http://localhost:3000/api/access-requests/[request_id]/reject
Next Steps (W4)
- Implement W4: Users & Roles management UI
- Add accept-invite page (/auth/accept-invite) for new users
- Add password setup flow for PENDING_INVITE users
- Add email notifications via Resend
Code Structure Summary
src/
├── app/
│ ├── admin/
│ │ └── access-requests/
│ │ ├── page.tsx (main dashboard)
│ │ └── _components/
│ │ ├── RequestTable.tsx (left panel)
│ │ └── RequestDetail.tsx (right panel)
│ └── api/
│ └── access-requests/
│ ├── route.ts (GET list + POST create)
│ └── [id]/
│ ├── route.ts (GET detail)
│ ├── approve/
│ │ └── route.ts (POST approve)
│ └── reject/
│ └── route.ts (POST reject)
├── components/
│ └── Toast.tsx (notifications)
└── lib/
├── auth/
│ ├── rbac.ts (role-based access control)
│ └── tokens.ts (invite token generation)
├── audit.ts (updated for W3 events)
└── ... (existing files)
prisma/
├── schema.prisma (updated)
└── migrations/
└── [timestamp]_add_admin_access_request_actions/