Skip to main content

Overview

The /api/admin/users endpoint provides comprehensive user management for administrators. It supports listing, filtering, role changes, password resets, and user deletion with built-in safety guardrails.
All methods require admin authentication. Users with app_metadata.user_type !== 'admin' receive 403 Forbidden.

Authentication

All requests require a Supabase access token from an admin user:
Authorization: Bearer <admin_access_token>
The endpoint validates:
  1. Token is valid and not expired
  2. User has app_metadata.user_type === 'admin'
All responses include:
Cache-Control: no-store
Never cache admin API responses.

GET /api/admin/users

List and search users with filtering, pagination, and platform statistics.

Query Parameters

Search term matched against email and display name (case-insensitive).
role
string
default:"all"
Filter by user role.Options:
  • all — All users
  • admin — Admin users only
  • member — Non-admin users only
confirmation
string
default:"all"
Filter by email confirmation status.Options:
  • all — All users
  • confirmed — Only users with confirmed emails
  • unconfirmed — Only users with unconfirmed emails
page
number
default:"1"
Page number (1-indexed). Min: 1, Max: 10000.
perPage
number
default:"25"
Items per page. Min: 1, Max: 100.

Response

users
object[]
Array of user objects for the current page.
id
string
Supabase user UUID
email
string | null
User email address
displayName
string
Resolved from user_metadata.full_name, user_metadata.name, user_metadata.display_name, or email. Defaults to "Unknown".
userType
'admin' | 'member'
Normalized from app_metadata.user_type
createdAt
string | null
ISO timestamp of account creation
lastSignInAt
string | null
ISO timestamp of most recent sign-in
emailConfirmedAt
string | null
ISO timestamp of email confirmation (null if unconfirmed)
providers
string[]
Authentication providers used (e.g., ["email", "google"])
pagination
object
Pagination metadata.
page
number
Current page number
perPage
number
Items per page
total
number
Total matching users (after filters)
totalPages
number
Total pages available
stats
object
Platform-wide statistics (cached for 60 seconds).
totalUsers
number
Total registered users
adminUsers
number
Count of admin users
recentlyActiveUsers7d
number
Users who signed in within the last 7 days
confirmedUsers
number
Users with confirmed email addresses
savedAds
number
Total saved ads across all users
projects
number
Total user projects
indexedAds
number
Total ads in the feed index

Example Request

cURL
curl "https://your-domain.vercel.app/api/admin/users?search=john&role=member&page=1&perPage=25" \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN"

Example Response

{
  "users": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "email": "[email protected]",
      "displayName": "John Doe",
      "userType": "member",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "lastSignInAt": "2026-03-02T08:45:00.000Z",
      "emailConfirmedAt": "2026-01-15T10:35:00.000Z",
      "providers": ["email", "google"]
    }
  ],
  "pagination": {
    "page": 1,
    "perPage": 25,
    "total": 1,
    "totalPages": 1
  },
  "stats": {
    "totalUsers": 127,
    "adminUsers": 3,
    "recentlyActiveUsers7d": 42,
    "confirmedUsers": 115,
    "savedAds": 1548,
    "projects": 89,
    "indexedAds": 12456
  }
}

Sorting Behavior

Users are sorted by (source:api/admin/users.js:186-194):
  1. Role — Admins first, then members
  2. Creation date — Newest first within each role group

Caching

The user list is cached in-memory for 15 seconds to avoid repeated full scans (source:api/admin/users.js:17). Statistics are cached separately for 60 seconds (source:api/admin/users.js:21).

PATCH /api/admin/users

Update a user’s role (admin/member).

Request Body

userId
string
required
UUID of the user to update
userType
string
required
New role for the user.Options:
  • admin
  • member

Safety Guardrails

The following operations are blocked to prevent lockout:
  1. Self-demotion: Admin cannot remove their own admin access
  2. Last admin protection: Cannot demote the last remaining admin
  3. Concurrent change detection: If multiple admins try to demote the last admin simultaneously, the operation is rolled back

Response

user
object
Updated user object (same structure as GET response)

Example Request

const response = await fetch('/api/admin/users', {
  method: 'PATCH',
  headers: {
    'Authorization': `Bearer ${adminToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    userId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
    userType: 'admin'
  })
})

const { user } = await response.json()

Error Cases

Self-Demotion Attempt (400)
{
  "error": "You cannot remove your own admin access."
}
Last Admin Protection (400)
{
  "error": "At least one admin user must remain."
}
Concurrent Change Rollback (409)
{
  "error": "Concurrent admin change detected. At least one admin must remain."
}

Implementation Detail

After demoting an admin, the endpoint performs a post-update verification (source:api/admin/users.js:362-376):
Safety Check
if (targetIsAdmin && userType !== 'admin') {
  const postUpdateAdminCount = await getAdminCount(client)
  if (postUpdateAdminCount < 1) {
    // Rollback: revert to admin
    await client.auth.admin.updateUserById(userId, {
      app_metadata: { user_type: 'admin' }
    })
    return { error: 'Concurrent admin change detected. At least one admin must remain.' }
  }
}
This prevents race conditions where multiple admins demote each other simultaneously.

POST /api/admin/users

Perform admin actions (currently: send password reset email).

Request Body

action
string
required
Action to perform.Options:
  • send_password_reset — Send password reset email to user
userId
string
required
UUID of the target user

Password Reset Flow

  1. Endpoint looks up user by ID
  2. Validates user has an email address
  3. Calls supabase.auth.resetPasswordForEmail() with admin-configured redirect URL
  4. User receives email with reset link
  5. Link redirects to app after password update

Redirect URL Resolution

The password reset redirect is determined by (in priority order):
  1. X-Forwarded-Host or Host header from request → /app path
  2. Supabase default (configured in project settings)

Example Request

const response = await fetch('/api/admin/users', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${adminToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    action: 'send_password_reset',
    userId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
  })
})

const { success } = await response.json()

Response

Success
{
  "success": true
}

Error Cases

User Not Found (404)
{
  "error": "User email not found."
}
Unsupported Action (400)
{
  "error": "Unsupported action."
}

DELETE /api/admin/users

Delete a user account.

Query Parameters

userId
string
required
UUID of the user to delete

Safety Guardrails

The following deletions are blocked:
  1. Self-deletion: Admin cannot delete their own account from this panel
  2. Last admin protection: Cannot delete the last remaining admin

Response

Success
{
  "success": true
}

Example Request

const response = await fetch(`/api/admin/users?userId=${userId}`, {
  method: 'DELETE',
  headers: {
    'Authorization': `Bearer ${adminToken}`
  }
})

const { success } = await response.json()

Error Cases

Self-Deletion Attempt (400)
{
  "error": "You cannot delete your own account from this panel."
}
Last Admin Protection (400)
{
  "error": "At least one admin user must remain."
}
User Not Found (404)
{
  "error": "User not found."
}

Complete Example: User Management Dashboard

React Component
import { useState, useEffect } from 'react'
import { useSupabaseClient } from '@/hooks/useSupabase'

export function UserManagement() {
  const [users, setUsers] = useState([])
  const [stats, setStats] = useState(null)
  const [loading, setLoading] = useState(true)
  const supabase = useSupabaseClient()
  
  const fetchUsers = async (page = 1, search = '') => {
    setLoading(true)
    
    const { data: { session } } = await supabase.auth.getSession()
    
    const params = new URLSearchParams({
      page: page.toString(),
      perPage: '25',
      search,
      role: 'all',
      confirmation: 'all'
    })
    
    const response = await fetch(`/api/admin/users?${params}`, {
      headers: {
        'Authorization': `Bearer ${session.access_token}`
      }
    })
    
    const data = await response.json()
    setUsers(data.users)
    setStats(data.stats)
    setLoading(false)
  }
  
  const promoteToAdmin = async (userId) => {
    const { data: { session } } = await supabase.auth.getSession()
    
    const response = await fetch('/api/admin/users', {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${session.access_token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ userId, userType: 'admin' })
    })
    
    if (response.ok) {
      await fetchUsers()
    } else {
      const { error } = await response.json()
      alert(error)
    }
  }
  
  const sendPasswordReset = async (userId) => {
    const { data: { session } } = await supabase.auth.getSession()
    
    const response = await fetch('/api/admin/users', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${session.access_token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        action: 'send_password_reset',
        userId
      })
    })
    
    if (response.ok) {
      alert('Password reset email sent')
    }
  }
  
  const deleteUser = async (userId) => {
    if (!confirm('Are you sure you want to delete this user?')) return
    
    const { data: { session } } = await supabase.auth.getSession()
    
    const response = await fetch(`/api/admin/users?userId=${userId}`, {
      method: 'DELETE',
      headers: {
        'Authorization': `Bearer ${session.access_token}`
      }
    })
    
    if (response.ok) {
      await fetchUsers()
    } else {
      const { error } = await response.json()
      alert(error)
    }
  }
  
  useEffect(() => {
    fetchUsers()
  }, [])
  
  if (loading) return <div>Loading...</div>
  
  return (
    <div>
      <h1>User Management</h1>
      
      {stats && (
        <div className="stats">
          <p>Total Users: {stats.totalUsers}</p>
          <p>Admins: {stats.adminUsers}</p>
          <p>Active (7d): {stats.recentlyActiveUsers7d}</p>
        </div>
      )}
      
      <table>
        <thead>
          <tr>
            <th>Email</th>
            <th>Role</th>
            <th>Last Sign In</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {users.map(user => (
            <tr key={user.id}>
              <td>{user.email}</td>
              <td>{user.userType}</td>
              <td>{user.lastSignInAt ? new Date(user.lastSignInAt).toLocaleDateString() : 'Never'}</td>
              <td>
                {user.userType === 'member' && (
                  <button onClick={() => promoteToAdmin(user.id)}>Promote to Admin</button>
                )}
                <button onClick={() => sendPasswordReset(user.id)}>Reset Password</button>
                <button onClick={() => deleteUser(user.id)}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

Best Practices

Respect cache TTL

User list is cached for 15s — avoid excessive polling

Handle 409 conflicts

Concurrent admin changes can trigger rollbacks — inform users to retry

Confirm destructive actions

Always confirm before deleting users or changing roles

Monitor admin count

Display admin count prominently to prevent accidental lockout
  • Overview — API authentication and error handling
  • Fanbasis — Payment webhook user provisioning

Build docs developers (and LLMs) love