Author name: Joylynne Grace C. Esportuno
Reviewers: Krizha Cortez
Creation Date: March 18, 2026
References: https://github.com/wyzlab/WyzQuests/issues/81
Status: Approved and Merged
Introduction & Goals
Problem Summary
The Suspend User feature allows platform admins to temporarily block a user from accessing authenticated WyzQuests pages and API routes without deleting the user account. Admins can suspend or unsuspend users from the Manage Users dashboard. Suspension is stored in Supabase on app_users.status and enforced by middleware for existing authenticated sessions.
Goals
Allow only platform
ADMINusers to suspend and unsuspend accounts.Persist suspension state in
app_users.status.Reflect status changes immediately in the admin Manage Users table.
Redirect suspended users to
/access-revokedfor protected page navigation.Return
403 ACCOUNT_SUSPENDEDfor protected API requests made by suspended users.Allow admins to reverse suspension by setting the status back to
Active.
Non-Goals
This feature does not delete users.
It does not change user roles.
It does not record an audit log entry for who suspended or unsuspended the user.
It does not require step-up re-authentication before suspend or unsuspend actions.
It does not notify the suspended user by email or in-app notification.
It does not preserve a suspension reason, expiration time, or notes.
Glossary
Platform admin — A user with
app_users.role === "ADMIN"Suspended user — A user whose
app_users.statusisSuspendedActive user — A user whose
app_users.statusisActiveClerk ID — External Clerk user ID stored in
app_users.clerk_id. Suspend and unsuspend APIs receive this ID asuserIdApp user ID — Internal Supabase UUID stored in
app_users.idProtected route — Any route that is not public in
middleware.ts
High-Level Architecture
System Diagram

Technologies Used
TypeScript, Next.js, Clerk, Supabase, Zod, Shadcn, Sonner toast
Detailed Design & Implementation
Data Model / Schema
No new database table is introduced for this feature. Suspension uses the existing app_users record.
The suspend and unsuspend API request schema is:
const suspendUserSchema = z.object({ userId: z.string().min(1, "User ID is required"),});
userId means app_users.clerk_id, not the internal app_users.id.
API Specification
#### GET /api/admin/list-users Authentication: Requires platform ADMIN through authenticateRole("ADMIN"). Purpose: Provides the Manage Users dashboard with users and current statuses. Selected fields ```ts id, clerk_id, name, email, role, status ``` Success response Uses ApiResponseHelper.success({ users: data }). #### POST /api/admin/suspend-user Authentication: Requires authenticateUserWithRole() and requesterRole === "ADMIN". Request body ```json { "userId": "clerk_user_id" } ``` Behavior Updates: ```ts supabase .from("app_users") .update({ status: "Suspended" }) .eq("clerk_id", userId); ``` Success response Uses ApiResponseHelper.success(null, "User suspended successfully"). Failure responses - Non-admin requester: 403 via ApiResponseHelper.forbidden("Only admins can suspend users"). - Invalid body: 400 VALIDATION_ERROR via ApiResponseHelper.validationError("Invalid request", parsed.error.flatten()). - Supabase update error: handled by ApiResponseHelper.handleError(dbError, "Failed to update user status"). - Unexpected error: handled by ApiResponseHelper.handleError(error, "Failed to suspend user."). #### POST /api/admin/unsuspend-user Authentication: Requires authenticateUserWithRole() and requesterRole === "ADMIN". Request body ```json { "userId": "clerk_user_id" } ``` Behavior Updates: ```ts supabase .from("app_users") .update({ status: "Active" }) .eq("clerk_id", userId); ``` Success response Uses ApiResponseHelper.success(null, "User unsuspended successfully"). Failure responses - Non-admin requester: 403 via ApiResponseHelper.forbidden("Only admins can unsuspend users"). - Invalid body: 400 VALIDATION_ERROR via ApiResponseHelper.validationError("Invalid request", parsed.error.flatten()). - Supabase update error: handled by ApiResponseHelper.handleError(dbError, "Failed to update user status"). - Unexpected error: handled by ApiResponseHelper.handleError(error, "Failed to unsuspend user").
Logic & Workflows
Admin Suspend Workflow
Implemented in app/admin/manage-users/page.tsx and components/admin/manage-users/SuspendDialog.tsx.
Admin opens
/admin/manage-users.The page calls
/api/admin/list-users.The table displays
statusfor each user.If a row has
status === "Active", the action menu showsSuspend.Admin confirms in
SuspendDialog.SuspendDialogposts{ userId: selectedUserId }to/api/admin/suspend-user.On success, the page updates local state from
ActivetoSuspendedfor the matchingclerk_id.A success toast displays
User has been suspended.
Admin Unsuspend Workflow
Implemented in app/admin/manage-users/page.tsx and components/admin/manage-users/UnsuspendDialog.tsx.
If a row is not
Active, the action menu showsUnsuspend.Admin confirms in
UnsuspendDialog.UnsuspendDialogposts{ userId: selectedUserId }to/api/admin/unsuspend-user.On success, the page updates local state from
SuspendedtoActivefor the matchingclerk_id.A success toast displays
User has been unsuspended.
Protected Route Enforcement
Implemented in middleware.ts.
Middleware lets public routes pass through, including
/access-revoked, sign-in, sign-up, public share routes, SCORM routes, guest review routes, and Clerk webhooks.For authenticated users on protected routes, middleware creates a Supabase service-role client.
It selects
statusfromapp_userswhereclerk_idmatches the ClerkuserId.If
status === "Suspended":
- API routes return JSON with status403and codeACCOUNT_SUSPENDED.
- Page requests redirect to/access-revoked.If the status lookup fails, middleware logs the error and fails open.
Sign-In Redirect Enforcement
Implemented in app/(main)/redirect-check/page.tsx.
After sign-in, the client calls
/api/get-role.Middleware can intercept that API call and return
403for suspended users.redirect-checktreats403as account suspension and redirects to/access-revoked.
Access Revoked UX
Implemented in app/(main)/access-revoked/page.tsx.
The page tells the user their access has been temporarily suspended and provides a Clerk SignOutButton redirecting to /sign-in.
Infrastructure & Operations
Dependencies
Dependency | Usage |
|---|---|
Clerk session/auth | Identifies the current user and provides |
Supabase | Stores role, |
Supabase service role key | Used by middleware to read |
| Required by middleware suspension check |
| Required by middleware suspension check |
Admin dashboard | Provides the operational UI for changing status |
Monitoring & Alerting
There is no dedicated monitoring or alerting for suspend/unsuspend actions.
Current observable signals:
Middleware logs missing Supabase environment variables for suspension checks.
Middleware logs Supabase suspension-check failures and fails open.
Admin dialogs log client-side request failures with
console.error.API routes return structured errors through
ApiResponseHelper.
Recommended future instrumentation:
Add audit logs for suspend and unsuspend actions with actor, target user, timestamp, and reason.
Alert when middleware suspension checks repeatedly fail.
Track
ACCOUNT_SUSPENDED403 responses by route.Add admin activity feed entries for user lifecycle changes.
Deployment Plan
No database migration is required for the current implementation if app_users.status already exists and supports Active and Suspended.
Safe rollout checklist:
Confirm
app_users.statuscontains consistent values:ActiveandSuspended.Confirm
NEXT_PUBLIC_SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare configured in the runtime that executes middleware.Deploy admin UI, API routes, middleware, and access-revoked page together.
Test suspend with a non-admin and verify the route returns
403.Test suspend with an admin and verify
app_users.statusupdates toSuspended.With the suspended account still signed in, navigate to a protected page and verify redirect to
/access-revoked.With the suspended account, call a protected API and verify
403 ACCOUNT_SUSPENDED.Unsuspend the account and verify protected navigation works again.
Testing & Quality Assurance
Test Strategy
Recommended tests:
API test for
/api/admin/suspend-userrejecting non-admin users.API test for
/api/admin/suspend-uservalidating missinguserId.API test for
/api/admin/suspend-userupdatingapp_users.statustoSuspended.API test for
/api/admin/unsuspend-userupdatingapp_users.statustoActive.Middleware test for suspended page navigation redirecting to
/access-revoked.Middleware test for suspended API requests returning
403 ACCOUNT_SUSPENDED.Component test for Manage Users showing
Suspendfor active users andUnsuspendfor suspended users.Component test for dialog success updating local status state.
Known Limitations
Suspend and unsuspend do not currently call Clerk's ban/unban APIs.
Existing Clerk sessions remain valid at the identity-provider level, but middleware blocks protected app access by checking Supabase status.
Middleware fails open if the Supabase status check errors.
The middleware status check depends on service-role environment variables being available.
Suspend/unsuspend endpoints do not verify that a matching user row was actually updated.
Suspend/unsuspend actions do not require re-authentication, unlike role changes and user deletion in the Manage Users page.
No audit trail records who suspended or unsuspended a user.
No reason, duration, or expiration is stored for suspensions.
A user with a status other than
Activeis treated as eligible for theUnsuspendmenu item in the UI, but middleware only blocks the exact valueSuspended.
Maintenance & Support
Troubleshooting
Symptom | Likely cause | Where to check |
|---|---|---|
Admin cnanot see Manage Users | User is not platform |
|
Suspend button returns forbidden | Requester role is not |
|
UI says suspended but user still has access | Middleware cannot read status, environment variables are missing, or user is on a public route |
|
Suspended user sees /acces-revoked immediately after sign-in | Middleware returned 403 during |
|
API requests return | The authenticated user’s |
|
Unsuspend succeeds but access is still blocked | Status did not update to exact |
|
Suspended status disappears after Clerk sync | Profile sync should not update status ; investigate manual DB changes or other sync logic | app/api/admin/sync-user/route.ts , app/api/clerk/user-updated/route.ts |
Changelog
1.0, Draft, Initial internal technical guide for the Suspend User feature, 06/22/2026
Document Version: 1.0, Approved, Initial internal technical guide for the Suspend User feature, 06/22/2026