Compare commits
2 Commits
c16ce3093c
...
92d96d0644
| Author | SHA1 | Date | |
|---|---|---|---|
| 92d96d0644 | |||
|
|
3e27a02e36 |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
113
src/app/hooks/useAdminUsers.ts
Normal file
113
src/app/hooks/useAdminUsers.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user