[6.3] Suspend User

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 ADMIN users 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-revoked for protected page navigation.

  • Return 403 ACCOUNT_SUSPENDED for 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.status is Suspended

  • Active user — A user whose app_users.status is Active

  • Clerk ID — External Clerk user ID stored in app_users.clerk_id. Suspend and unsuspend APIs receive this ID as userId

  • App user ID — Internal Supabase UUID stored in app_users.id

  • Protected 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.

  1. Admin opens /admin/manage-users.

  2. The page calls /api/admin/list-users.

  3. The table displays status for each user.

  4. If a row has status === "Active", the action menu shows Suspend.

  5. Admin confirms in SuspendDialog.

  6. SuspendDialog posts { userId: selectedUserId } to /api/admin/suspend-user.

  7. On success, the page updates local state from Active to Suspended for the matching clerk_id.

  8. 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.

  1. If a row is not Active, the action menu shows Unsuspend.

  2. Admin confirms in UnsuspendDialog.

  3. UnsuspendDialog posts { userId: selectedUserId } to /api/admin/unsuspend-user.

  4. On success, the page updates local state from Suspended to Active for the matching clerk_id.

  5. A success toast displays User has been unsuspended.

Protected Route Enforcement

Implemented in middleware.ts.

  1. Middleware lets public routes pass through, including /access-revoked, sign-in, sign-up, public share routes, SCORM routes, guest review routes, and Clerk webhooks.

  2. For authenticated users on protected routes, middleware creates a Supabase service-role client.

  3. It selects status from app_users where clerk_id matches the Clerk userId.

  4. If status === "Suspended":
    - API routes return JSON with status 403 and code ACCOUNT_SUSPENDED.
    - Page requests redirect to /access-revoked.

  5. If the status lookup fails, middleware logs the error and fails open.

Sign-In Redirect Enforcement

Implemented in app/(main)/redirect-check/page.tsx.

  1. After sign-in, the client calls /api/get-role.

  2. Middleware can intercept that API call and return 403 for suspended users.

  3. redirect-check treats 403 as 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 userId

Supabase app_users table

Stores role, clerk_id, and status

Supabase service role key

Used by middleware to read app_users.status

NEXT_PUBLIC_SUPABASE_URL

Required by middleware suspension check

SUPABASE_SERVICE_ROLE_KEY

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_SUSPENDED 403 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:

  1. Confirm app_users.status contains consistent values: Active and Suspended.

  2. Confirm NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are configured in the runtime that executes middleware.

  3. Deploy admin UI, API routes, middleware, and access-revoked page together.

  4. Test suspend with a non-admin and verify the route returns 403.

  5. Test suspend with an admin and verify app_users.status updates to Suspended.

  6. With the suspended account still signed in, navigate to a protected page and verify redirect to /access-revoked.

  7. With the suspended account, call a protected API and verify 403 ACCOUNT_SUSPENDED.

  8. Unsuspend the account and verify protected navigation works again.


Testing & Quality Assurance

Test Strategy

Recommended tests:

  • API test for /api/admin/suspend-user rejecting non-admin users.

  • API test for /api/admin/suspend-user validating missing userId.

  • API test for /api/admin/suspend-user updating app_users.status to Suspended.

  • API test for /api/admin/unsuspend-user updating app_users.status to Active.

  • 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 Suspend for active users and Unsuspend for 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 Active is treated as eligible for the Unsuspend menu item in the UI, but middleware only blocks the exact value Suspended.


Maintenance & Support

Troubleshooting

Symptom

Likely cause

Where to check

Admin cnanot see Manage Users

User is not platform ADMIN

app/api/admin/list-users/route.ts , lib/auth/authenticate.ts

Suspend button returns forbidden

Requester role is not ADMIN

app/admin/suspend-user/route.ts

UI says suspended but user still has access

Middleware cannot read status, environment variables are missing, or user is on a public route

middleware.ts , runtime env vars, route matcher

Suspended user sees /acces-revoked immediately after sign-in

Middleware returned 403 during /api/get-role or intercepted protected navigation

app/(main)/redirect-check/page.tsx , middleware.ts

API requests return ACCOUNT_SUSPENDED

The authenticated user’s app_users.status is suspended

middlware.ts , app_users.status

Unsuspend succeeds but access is still blocked

Status did not update to exact Active, or cached/browser navigation is stale

app/api/admin/unsuspend-user/route.ts , app_users.status

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


Was this article helpful?