feat: implement admin user management features including fetching, verifying, and displaying user stats #3

Merged
Seazn merged 1 commits from admin-site into dev 2025-10-15 16:48:13 +00:00
5 changed files with 586 additions and 162 deletions
Showing only changes of commit 3e27a02e36 - Show all commits

View File

@ -9,21 +9,29 @@ import {
ServerStackIcon,
ArrowRightIcon
} from '@heroicons/react/24/outline'
import { useMemo } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers'
export default function AdminDashboardPage() {
const router = useRouter()
const { userStats, isAdmin } = useAdminUsers()
const [isClient, setIsClient] = useState(false)
// Mocked aggregates (replace with real fetch)
const userStats = useMemo(() => ({
total: 101,
admins: 1,
pending: 3,
active: 54,
personal: 94,
company: 7
}), [])
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
// Fallback for loading/no data
const displayStats = userStats || {
totalUsers: 0,
adminUsers: 0,
verificationPending: 0,
activeUsers: 0,
personalUsers: 0,
companyUsers: 0
}
const permissionStats = useMemo(() => ({
permissions: 1 // TODO: fetch permission definitions
@ -37,6 +45,38 @@ export default function AdminDashboardPage() {
recentErrors: [] as { id: string; ts: string; msg: string }[]
}), [])
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading...</p>
</div>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-red-500/20 p-6 sm:p-10">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
@ -105,27 +145,51 @@ export default function AdminDashboardPage() {
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-3 text-xs sm:text-sm mb-6">
<div>
<dt className="text-gray-500">Total Users</dt>
<dd className="font-semibold text-gray-900">{userStats.total}</dd>
<dd className="font-semibold text-gray-900">
{userStats ? displayStats.totalUsers : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Admin Users</dt>
<dd className="font-semibold text-gray-900">{userStats.admins}</dd>
<dd className="font-semibold text-gray-900">
{userStats ? displayStats.adminUsers : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Verification Pending</dt>
<dd className="font-semibold text-amber-600">{userStats.pending}</dd>
<dd className="font-semibold text-amber-600">
{userStats ? displayStats.verificationPending : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Active Users</dt>
<dd className="font-semibold text-emerald-600">{userStats.active}</dd>
<dd className="font-semibold text-emerald-600">
{userStats ? displayStats.activeUsers : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Personal Users</dt>
<dd className="font-semibold text-gray-900">{userStats.personal}</dd>
<dd className="font-semibold text-gray-900">
{userStats ? displayStats.personalUsers : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
<div>
<dt className="text-gray-500">Company Users</dt>
<dd className="font-semibold text-gray-900">{userStats.company}</dd>
<dd className="font-semibold text-gray-900">
{userStats ? displayStats.companyUsers : (
<span className="inline-block w-8 h-4 bg-gray-200 rounded animate-pulse" />
)}
</dd>
</div>
</dl>
<button

View File

@ -1,69 +1,86 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, useEffect, useCallback } from 'react'
import PageLayout from '../../components/PageLayout'
import {
MagnifyingGlassIcon,
EyeIcon,
PencilSquareIcon,
XMarkIcon
XMarkIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { useAdminUsers } from '../../hooks/useAdminUsers'
import { AdminAPI } from '../../utils/api'
import useAuthStore from '../../store/authStore'
type UserType = 'personal' | 'company'
type UserStatus = 'active' | 'pending' | 'disabled'
type UserRole = 'user' | 'admin'
interface User {
id: string
firstName: string
lastName: string
id: number
email: string
type: UserType
status: UserStatus
user_type: UserType
role: UserRole
created: string
lastLogin: string | null
created_at: string
last_login_at: string | null
status: string
is_admin_verified: number
first_name?: string
last_name?: string
company_name?: string
}
const STATUSES: UserStatus[] = ['active','pending','disabled']
const TYPES: UserType[] = ['personal','company']
const ROLES: UserRole[] = ['user','admin']
// Helpers
const rand = (arr: any[]) => arr[Math.floor(Math.random() * arr.length)]
const daysAgo = (d: number) => {
const dt = new Date()
dt.setDate(dt.getDate() - d)
return dt.toISOString().slice(0,10)
}
export default function AdminUserManagementPage() {
// Mock users (stable memo)
const users = useMemo<User[]>(() => {
const firstNames = ['Anka','Peter','Primoz','Bojana','Hiska','Augst','Katarina','Tamara','Darija','Luka','Sara','Jonas','Maja','Niko','Eva']
const lastNames = ['Eravec','Oblak','Sranic','Pilih','Kaja','Feviz','Kravar','Skusek','Abersek','Novak','Schmidt','Mueller','Keller','Hansen','Mayr']
const list: User[] = []
for (let i=0;i<101;i++){
const fn = rand(firstNames)
const ln = rand(lastNames)
const createdDays = Math.floor(Math.random()*15)
const loginDays = Math.random() > 0.2 ? Math.floor(Math.random()*15) : null
list.push({
id: `U${i+1}`,
firstName: fn,
lastName: ln,
email: `${fn}.${ln}${i}@example.com`.toLowerCase(),
type: Math.random() > 0.92 ? 'company' : 'personal',
status: rand(STATUSES),
role: Math.random() > 0.96 ? 'admin' : 'user',
created: daysAgo(createdDays),
lastLogin: loginDays === null ? null : daysAgo(loginDays)
})
}
return list
const { isAdmin } = useAdminUsers()
const token = useAuthStore(state => state.accessToken)
const [isClient, setIsClient] = useState(false)
// State for all users (not just pending)
const [allUsers, setAllUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
// Filters
// Fetch all users from backend
const fetchAllUsers = useCallback(async () => {
if (!token || !isAdmin) return
setLoading(true)
setError(null)
try {
const response = await AdminAPI.getUserList(token)
if (response.success) {
setAllUsers(response.users || [])
} else {
throw new Error(response.message || 'Failed to fetch users')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
setError(errorMessage)
console.error('AdminUserManagement.fetchAllUsers error:', err)
} finally {
setLoading(false)
}
}, [token, isAdmin])
// Load users on mount
useEffect(() => {
if (isClient && isAdmin && token) {
fetchAllUsers()
}
}, [fetchAllUsers, isClient])
// Filter hooks - must be declared before conditional returns
const [search, setSearch] = useState('')
const [fType, setFType] = useState<'all'|UserType>('all')
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
@ -72,20 +89,68 @@ export default function AdminUserManagementPage() {
const PAGE_SIZE = 10
const filtered = useMemo(() => {
return users.filter(u =>
(fType==='all'||u.type===fType) &&
(fStatus==='all'||u.status===fStatus) &&
(fRole==='all'||u.role===fRole) &&
return allUsers.filter(u => {
const firstName = u.first_name || ''
const lastName = u.last_name || ''
const companyName = u.company_name || ''
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
// Map backend status to frontend status
// Backend status can be: 'pending', 'active', 'suspended', etc.
// is_admin_verified: 1 = verified by admin, 0 = not verified
const userStatus: UserStatus = u.is_admin_verified === 1 ? 'active' :
u.status === 'pending' ? 'pending' :
u.status === 'suspended' ? 'disabled' :
'pending' // default fallback
return (
(fType === 'all' || u.user_type === fType) &&
(fStatus === 'all' || userStatus === fStatus) &&
(fRole === 'all' || u.role === fRole) &&
(
!search.trim() ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
fullName.toLowerCase().includes(search.toLowerCase())
)
)
}, [users, search, fType, fStatus, fRole])
})
}, [allUsers, search, fType, fStatus, fRole])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const current = filtered.slice((page-1)*PAGE_SIZE, page*PAGE_SIZE)
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading...</p>
</div>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-red-500/20 p-6 sm:p-10">
<div className="text-center">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
const applyFilter = (e: React.FormEvent) => {
e.preventDefault()
@ -158,6 +223,23 @@ export default function AdminUserManagementPage() {
</p>
</div>
{/* Error Message */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 text-red-700 px-5 py-4 flex gap-3 items-start">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
<div>
<p className="font-semibold">Error loading users</p>
<p className="text-sm text-red-600">{error}</p>
<button
onClick={fetchAllUsers}
className="mt-2 text-sm underline hover:no-underline"
>
Try again
</button>
</div>
</div>
)}
{/* Filter Card */}
<form
onSubmit={applyFilter}
@ -249,8 +331,33 @@ export default function AdminUserManagementPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{current.map(u => {
const initials = `${u.firstName[0]||''}${u.lastName[0]||''}`.toUpperCase()
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
<span className="text-sm text-gray-500">Loading users...</span>
</div>
</td>
</tr>
) : current.map(u => {
const displayName = u.user_type === 'company'
? u.company_name || 'Unknown Company'
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
const initials = u.user_type === 'company'
? (u.company_name?.[0] || 'C').toUpperCase()
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
// Map backend status to frontend status for display
const userStatus: UserStatus = u.is_admin_verified === 1 ? 'active' :
u.status === 'pending' ? 'pending' :
u.status === 'suspended' ? 'disabled' :
'pending' // default fallback
const createdDate = new Date(u.created_at).toLocaleDateString()
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
return (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
@ -260,7 +367,7 @@ export default function AdminUserManagementPage() {
</div>
<div>
<div className="font-medium text-gray-900 leading-tight">
{u.firstName} {u.lastName}
{displayName}
</div>
<div className="text-[11px] text-gray-500">
{u.email}
@ -268,29 +375,29 @@ export default function AdminUserManagementPage() {
</div>
</div>
</td>
<td className="px-4 py-3">{typeBadge(u.type)}</td>
<td className="px-4 py-3">{statusBadge(u.status)}</td>
<td className="px-4 py-3">{typeBadge(u.user_type)}</td>
<td className="px-4 py-3">{statusBadge(userStatus)}</td>
<td className="px-4 py-3">{roleBadge(u.role)}</td>
<td className="px-4 py-3 text-gray-700">{u.created}</td>
<td className="px-4 py-3 text-gray-700">{createdDate}</td>
<td className="px-4 py-3 text-gray-500 italic">
{u.lastLogin ?? 'Never'}
{lastLoginDate}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => onView(u.id)}
onClick={() => onView(u.id.toString())}
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-700 px-2.5 py-1 text-xs font-medium transition"
>
<EyeIcon className="h-4 w-4" /> View
</button>
<button
onClick={() => onEdit(u.id)}
onClick={() => onEdit(u.id.toString())}
className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 hover:bg-amber-100 text-amber-700 px-2.5 py-1 text-xs font-medium transition"
>
<PencilSquareIcon className="h-4 w-4" /> Edit
</button>
<button
onClick={() => onDelete(u.id)}
onClick={() => onDelete(u.id.toString())}
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 hover:bg-red-100 text-red-600 px-2.5 py-1 text-xs font-medium transition"
>
<XMarkIcon className="h-4 w-4" /> Delete

View File

@ -1,79 +1,59 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import { useMemo, useState, useEffect } from 'react'
import PageLayout from '../../components/PageLayout'
import {
MagnifyingGlassIcon,
CheckIcon
CheckIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { useAdminUsers } from '../../hooks/useAdminUsers'
import { PendingUser } from '../../utils/api'
type UserType = 'personal' | 'company'
type UserRole = 'user' | 'admin'
interface PendingUser {
id: string
firstName: string
lastName: string
email: string
type: UserType
role: UserRole
created: string
lastLogin: string
status: 'pending' | 'verifying' | 'active'
}
const TYPES: UserType[] = ['personal', 'company']
const ROLES: UserRole[] = ['user', 'admin']
const rand = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)]
const daysAgo = (d: number) => {
const dt = new Date()
dt.setDate(dt.getDate() - d)
return dt.toISOString().slice(0, 10)
}
export default function AdminUserVerifyPage() {
// Mock pending users (only a small subset)
const seedUsers = useMemo<PendingUser[]>(() => {
const first = ['Avgust', 'Katarina', 'Ana', 'Luka', 'Sara', 'Jonas', 'Niko', 'Eva']
const last = ['Senica', 'Fedzer', 'Hochkraut', 'Novak', 'Schmidt', 'Keller', 'Mayr', 'Hansen']
const list: PendingUser[] = []
for (let i = 0; i < 18; i++) {
const fn = rand(first)
const ln = rand(last)
list.push({
id: `P${i + 1}`,
firstName: fn,
lastName: ln,
email: `${fn}.${ln}${i}@example.com`.toLowerCase(),
type: Math.random() > 0.9 ? 'company' : 'personal',
role: Math.random() > 0.95 ? 'admin' : 'user',
created: daysAgo(Math.floor(Math.random() * 12)),
lastLogin: daysAgo(Math.floor(Math.random() * 5)),
status: 'pending'
})
}
return list
}, [])
const {
pendingUsers,
loading,
error,
verifying,
verifyUser: handleVerifyUser,
isAdmin,
fetchPendingUsers
} = useAdminUsers()
const [isClient, setIsClient] = useState(false)
const [users, setUsers] = useState<PendingUser[]>(seedUsers)
// Handle client-side mounting
useEffect(() => {
setIsClient(true)
}, [])
const [search, setSearch] = useState('')
const [fType, setFType] = useState<'all' | UserType>('all')
const [fRole, setFRole] = useState<'all' | UserRole>('all')
const [perPage, setPerPage] = useState(10)
const [page, setPage] = useState(1)
// All computations must be after hooks but before conditional returns
const filtered = useMemo(() => {
return users.filter(u =>
u.status === 'pending' &&
(fType === 'all' || u.type === fType) &&
return pendingUsers.filter(u => {
const firstName = u.first_name || ''
const lastName = u.last_name || ''
const companyName = u.company_name || ''
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
return (
(fType === 'all' || u.user_type === fType) &&
(fRole === 'all' || u.role === fRole) &&
(
!search.trim() ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
fullName.toLowerCase().includes(search.toLowerCase())
)
)
}, [users, search, fType, fRole])
})
}, [pendingUsers, search, fType, fRole])
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
const current = filtered.slice((page - 1) * perPage, page * perPage)
@ -104,13 +84,56 @@ export default function AdminUserVerifyPage() {
return badge('Active', 'bg-green-100 text-green-700')
}
const verifyUser = useCallback((id: string) => {
setUsers(prev => prev.map(u => u.id === id ? { ...u, status: 'verifying' } : u))
// simulate async approval
setTimeout(() => {
setUsers(prev => prev.map(u => u.id === id ? { ...u, status: 'active' } : u))
}, 900)
}, [])
const verificationStatusBadge = (user: PendingUser) => {
const steps = [
{ name: 'Email', completed: user.email_verified === 1 },
{ name: 'Profile', completed: user.profile_completed === 1 },
{ name: 'Documents', completed: user.documents_uploaded === 1 },
{ name: 'Contract', completed: user.contract_signed === 1 }
]
const completedSteps = steps.filter(s => s.completed).length
const totalSteps = steps.length
if (completedSteps === totalSteps) {
return badge('Ready to Verify', 'bg-green-100 text-green-700')
} else {
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
}
}
// Show loading during SSR/initial client render
if (!isClient) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading...</p>
</div>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-red-500/20 p-6 sm:p-10">
<div className="text-center">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
</div>
</div>
</div>
</PageLayout>
)
}
return (
<PageLayout>
@ -141,13 +164,30 @@ export default function AdminUserVerifyPage() {
<div className="relative mx-auto w-full max-w-7xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-[#0e2f63]">
Users Pending Verification
User Verification Center
</h1>
<p className="mt-2 text-sm sm:text-base text-[#33507d] font-medium">
Review and verify users who have completed all registration steps.
Review and verify all users who need admin approval. Users must complete all steps before verification.
</p>
</div>
{/* Error Message */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 text-red-700 px-5 py-4 flex gap-3 items-start">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
<div>
<p className="font-semibold">Error loading data</p>
<p className="text-sm text-red-600">{error}</p>
<button
onClick={fetchPendingUsers}
className="mt-2 text-sm underline hover:no-underline"
>
Try again
</button>
</div>
</div>
)}
{/* Filter Card */}
<form
onSubmit={applyFilters}
@ -227,16 +267,39 @@ export default function AdminUserVerifyPage() {
<tr>
<th className="px-4 py-2 text-left">User</th>
<th className="px-4 py-2 text-left">Type</th>
<th className="px-4 py-2 text-left">Progress</th>
<th className="px-4 py-2 text-left">Status</th>
<th className="px-4 py-2 text-left">Role</th>
<th className="px-4 py-2 text-left">Created</th>
<th className="px-4 py-2 text-left">Last Login</th>
<th className="px-4 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{current.map(u => {
const initials = `${u.firstName[0]}${u.lastName[0]}`.toUpperCase()
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
<span className="text-sm text-gray-500">Loading users...</span>
</div>
</td>
</tr>
) : current.map(u => {
const displayName = u.user_type === 'company'
? u.company_name || 'Unknown Company'
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
const initials = u.user_type === 'company'
? (u.company_name?.[0] || 'C').toUpperCase()
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
const isVerifying = verifying.has(u.id.toString())
const createdDate = new Date(u.created_at).toLocaleDateString()
// Check if user has completed all verification steps
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
u.documents_uploaded === 1 && u.contract_signed === 1
return (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
@ -246,31 +309,29 @@ export default function AdminUserVerifyPage() {
</div>
<div>
<div className="font-medium text-gray-900 leading-tight">
{u.firstName} {u.lastName}
{displayName}
</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">{typeBadge(u.type)}</td>
<td className="px-4 py-3">{typeBadge(u.user_type)}</td>
<td className="px-4 py-3">{verificationStatusBadge(u)}</td>
<td className="px-4 py-3">{statusBadge(u.status)}</td>
<td className="px-4 py-3">{roleBadge(u.role)}</td>
<td className="px-4 py-3 text-gray-700">{u.created}</td>
<td className="px-4 py-3 text-gray-500 italic">{u.lastLogin}</td>
<td className="px-4 py-3 text-gray-700">{createdDate}</td>
<td className="px-4 py-3">
{u.status === 'active' ? (
<span className="text-xs font-medium text-emerald-600">Verified</span>
) : (
{isReadyToVerify ? (
<button
onClick={() => verifyUser(u.id)}
disabled={u.status !== 'pending'}
onClick={() => handleVerifyUser(u.id.toString())}
disabled={isVerifying}
className={`inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-medium transition
${u.status === 'pending'
? 'border-emerald-200 bg-emerald-50 hover:bg-emerald-100 text-emerald-700'
: 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
${isVerifying
? 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
: 'border-emerald-200 bg-emerald-50 hover:bg-emerald-100 text-emerald-700'
}`}
>
{u.status === 'verifying' ? (
{isVerifying ? (
<>
<span className="h-3 w-3 rounded-full border-2 border-emerald-500 border-b-transparent animate-spin" />
Verifying...
@ -281,15 +342,17 @@ export default function AdminUserVerifyPage() {
</>
)}
</button>
) : (
<span className="text-xs text-gray-500 italic">Incomplete steps</span>
)}
</td>
</tr>
)
})}
{current.length === 0 && (
{current.length === 0 && !loading && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-sm text-gray-500">
No pending users match current filters.
No unverified users match current filters.
</td>
</tr>
)}

View File

@ -0,0 +1,113 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { AdminAPI, PendingUser, AdminUserStats } from '../utils/api'
import useAuthStore from '../store/authStore'
export const useAdminUsers = () => {
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([])
const [userStats, setUserStats] = useState<AdminUserStats | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [verifying, setVerifying] = useState<Set<string>>(new Set())
const user = useAuthStore(state => state.user)
const token = useAuthStore(state => state.accessToken)
const fetchPendingUsers = useCallback(async () => {
if (!token || !user?.role || !['admin', 'super_admin'].includes(user.role)) {
setError('Admin access required')
return
}
setLoading(true)
setError(null)
try {
// Use the new unverified users endpoint instead of verification pending
const response = await AdminAPI.getUnverifiedUsers(token)
if (response.success) {
setPendingUsers(response.users || [])
} else {
throw new Error(response.message || 'Failed to fetch unverified users')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch unverified users'
setError(errorMessage)
console.error('useAdminUsers.fetchPendingUsers error:', err)
} finally {
setLoading(false)
}
}, [token, user?.role])
const fetchUserStats = useCallback(async () => {
if (!token || !user?.role || !['admin', 'super_admin'].includes(user.role)) {
return
}
try {
const response = await AdminAPI.getUserStats(token)
if (response.success) {
setUserStats(response.stats)
}
} catch (err) {
console.error('useAdminUsers.fetchUserStats error:', err)
}
}, [token, user?.role])
const verifyUser = useCallback(async (userId: string, permissions: string[] = []) => {
if (!token) {
setError('No authentication token')
return false
}
setVerifying(prev => new Set(prev).add(userId))
setError(null)
try {
const response = await AdminAPI.verifyUser(token, userId, permissions)
if (response.success) {
// Remove user from pending list
setPendingUsers(prev => prev.filter(u => u.id.toString() !== userId))
// Refresh stats
await fetchUserStats()
return true
} else {
throw new Error(response.message || 'Failed to verify user')
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to verify user'
setError(errorMessage)
console.error('useAdminUsers.verifyUser error:', err)
return false
} finally {
setVerifying(prev => {
const newSet = new Set(prev)
newSet.delete(userId)
return newSet
})
}
}, [token, fetchUserStats])
// Auto-fetch on mount and when dependencies change
useEffect(() => {
if (token && user?.role && ['admin', 'super_admin'].includes(user.role)) {
fetchPendingUsers()
fetchUserStats()
}
}, [fetchPendingUsers, fetchUserStats])
return {
pendingUsers,
userStats,
loading,
error,
verifying,
fetchPendingUsers,
fetchUserStats,
verifyUser,
isAdmin: user?.role && ['admin', 'super_admin'].includes(user.role)
}
}

View File

@ -35,6 +35,11 @@ export const API_ENDPOINTS = {
// Admin
ADMIN_USERS: '/api/admin/users/:id/full',
ADMIN_USER_STATS: '/api/admin/user-stats',
ADMIN_USER_LIST: '/api/admin/user-list',
ADMIN_VERIFICATION_PENDING: '/api/admin/verification-pending-users',
ADMIN_UNVERIFIED_USERS: '/api/admin/unverified-users',
ADMIN_VERIFY_USER: '/api/admin/verify-user/:id',
}
// API Helper Functions
@ -226,6 +231,51 @@ export interface ApiError {
code?: string
}
// Admin API Functions
export class AdminAPI {
static async getUserStats(token: string) {
const response = await ApiClient.get(API_ENDPOINTS.ADMIN_USER_STATS, token)
if (!response.ok) {
throw new Error('Failed to fetch user stats')
}
return response.json()
}
static async getUserList(token: string) {
const response = await ApiClient.get(API_ENDPOINTS.ADMIN_USER_LIST, token)
if (!response.ok) {
throw new Error('Failed to fetch user list')
}
return response.json()
}
static async getVerificationPendingUsers(token: string) {
const response = await ApiClient.get(API_ENDPOINTS.ADMIN_VERIFICATION_PENDING, token)
if (!response.ok) {
throw new Error('Failed to fetch pending users')
}
return response.json()
}
static async getUnverifiedUsers(token: string) {
const response = await ApiClient.get(API_ENDPOINTS.ADMIN_UNVERIFIED_USERS, token)
if (!response.ok) {
throw new Error('Failed to fetch unverified users')
}
return response.json()
}
static async verifyUser(token: string, userId: string, permissions: string[] = []) {
const endpoint = API_ENDPOINTS.ADMIN_VERIFY_USER.replace(':id', userId)
const response = await ApiClient.post(endpoint, { permissions }, token)
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to verify user')
}
return response.json()
}
}
// Response Types
export interface UserStatus {
emailVerified: boolean
@ -234,6 +284,33 @@ export interface UserStatus {
contractSigned: boolean
}
export interface AdminUserStats {
totalUsers: number
adminUsers: number
verificationPending: number
activeUsers: number
personalUsers: number
companyUsers: number
}
export interface PendingUser {
id: number
email: string
user_type: 'personal' | 'company'
role: 'user' | 'admin'
created_at: string
last_login_at: string | null
status: string
is_admin_verified: number
email_verified?: number
profile_completed?: number
documents_uploaded?: number
contract_signed?: number
first_name?: string
last_name?: string
company_name?: string
}
export interface ApiResponse<T = any> {
success: boolean
message?: string