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

W3 Implementation: Admin Access Requests

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.

W3-ADMIN-ACCESS-REQUESTS-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.

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 authTokens
  • AccessRequest – 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

Sub-components

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

GET /api/access-requests (Admin List)

Query params:

  • status – filter by PENDING/APPROVED/REJECTED (default: PENDING)
  • role – filter by HR/MANAGER/SMO/ADMIN
  • q – search name/email/requestCode
  • page – 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:

  1. Verify request exists and status = PENDING (else 409)
  2. 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
  3. Update user role if different
  4. Generate invite token (7-day expiry)
  5. Update AccessRequest: status=APPROVED, decidedBy, decidedAt
  6. Log audit events: USER_CREATED (if new), ROLE_ASSIGNED, ACCESS_REQUEST_APPROVED
  7. 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:

  1. Verify request exists and status = PENDING (else 409)
  2. Update AccessRequest: status=REJECTED, decidedBy, decidedAt, decisionNote
  3. Log audit event: ACCESS_REQUEST_REJECTED
  4. Return 200

F. RBAC Checks

Access Control

  • /admin/access-requests page: 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

  1. Run migration: npx prisma migrate dev --name add_admin_access_request_actions
  2. Update seed to create admin user (already exists from W1)
  3. Create test access requests via W2 (/request-access)
  4. Run npm run dev

Authentication & Authorization

  • Visit /admin/access-requests as 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/