feat(admin): add user search and sorting
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { User, UserRole } from '../../types';
|
import { User, UserRole } from '../../types';
|
||||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||||
import { databaseService } from '../../services/database';
|
import { databaseService } from '../../services/database';
|
||||||
@@ -15,12 +15,19 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<
|
||||||
|
'createdAt' | 'status' | 'role' | 'username'
|
||||||
|
>('createdAt');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const users = await databaseService.getAllUsers();
|
const users = await databaseService.getAllUsers();
|
||||||
setUsers(users);
|
setUsers(users);
|
||||||
@@ -107,6 +114,88 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
: 'text-blue-600 bg-blue-100';
|
: 'text-blue-600 bg-blue-100';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusPriority: Record<AccountStatus, number> = {
|
||||||
|
[AccountStatus.ACTIVE]: 0,
|
||||||
|
[AccountStatus.PENDING]: 1,
|
||||||
|
[AccountStatus.SUSPENDED]: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rolePriority: Record<UserRole, number> = {
|
||||||
|
[UserRole.ADMIN]: 0,
|
||||||
|
[UserRole.USER]: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusPriority = (status?: AccountStatus) =>
|
||||||
|
statusPriority[status as AccountStatus] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
const getRolePriority = (role?: UserRole) =>
|
||||||
|
rolePriority[role as UserRole] ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
const sortedUsers = useMemo(() => {
|
||||||
|
const copy = [...users];
|
||||||
|
copy.sort((a, b) => {
|
||||||
|
let result = 0;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'status':
|
||||||
|
result = getStatusPriority(a.status) - getStatusPriority(b.status);
|
||||||
|
break;
|
||||||
|
case 'role':
|
||||||
|
result = getRolePriority(a.role) - getRolePriority(b.role);
|
||||||
|
break;
|
||||||
|
case 'username':
|
||||||
|
result = (a.username || '').localeCompare(
|
||||||
|
b.username || '',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
sensitivity: 'base',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
default: {
|
||||||
|
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
result = timeA - timeB;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 0) {
|
||||||
|
result = (a.username || '').localeCompare(b.username || '', undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 0) {
|
||||||
|
result = (a.email || '').localeCompare(b.email || '', undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortDirection === 'asc' ? result : -result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}, [users, sortField, sortDirection]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
return sortedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchTerm.trim().toLowerCase();
|
||||||
|
return sortedUsers.filter(user => {
|
||||||
|
const username = user.username?.toLowerCase() ?? '';
|
||||||
|
const email = user.email?.toLowerCase() ?? '';
|
||||||
|
return username.includes(query) || email.includes(query);
|
||||||
|
});
|
||||||
|
}, [sortedUsers, searchTerm]);
|
||||||
|
|
||||||
|
const visibleUsersLabel = searchTerm.trim()
|
||||||
|
? `${filteredUsers.length} of ${users.length} users`
|
||||||
|
: `${filteredUsers.length} users`;
|
||||||
|
|
||||||
if (currentUser?.role !== UserRole.ADMIN) {
|
if (currentUser?.role !== UserRole.ADMIN) {
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||||
@@ -173,7 +262,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||||||
User Management ({users.length} users)
|
User Management ({visibleUsersLabel})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={loadUsers}
|
onClick={loadUsers}
|
||||||
@@ -184,129 +273,191 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-4'>
|
||||||
<thead className='bg-slate-50 dark:bg-slate-600'>
|
<div className='w-full sm:w-72'>
|
||||||
<tr>
|
<label htmlFor='admin-search' className='sr-only'>
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
Search users
|
||||||
User
|
</label>
|
||||||
</th>
|
<input
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
id='admin-search'
|
||||||
Email
|
type='search'
|
||||||
</th>
|
value={searchTerm}
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
Status
|
placeholder='Search by username or email'
|
||||||
</th>
|
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
/>
|
||||||
Role
|
</div>
|
||||||
</th>
|
<div className='flex items-center gap-2'>
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
<label
|
||||||
Created
|
htmlFor='admin-sort'
|
||||||
</th>
|
className='text-sm text-slate-600 dark:text-slate-300'
|
||||||
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
>
|
||||||
Actions
|
Sort by
|
||||||
</th>
|
</label>
|
||||||
</tr>
|
<select
|
||||||
</thead>
|
id='admin-sort'
|
||||||
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
value={sortField}
|
||||||
{users.map(user => (
|
onChange={e =>
|
||||||
<tr
|
setSortField(
|
||||||
key={user._id}
|
e.target.value as
|
||||||
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
| 'createdAt'
|
||||||
>
|
| 'status'
|
||||||
<td className='px-4 py-4'>
|
| 'role'
|
||||||
<div className='flex items-center'>
|
| 'username'
|
||||||
{user.avatar ? (
|
)
|
||||||
<img
|
}
|
||||||
src={user.avatar}
|
className='px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
|
||||||
alt={user.username}
|
>
|
||||||
className='w-8 h-8 rounded-full mr-3'
|
<option value='createdAt'>Created Date</option>
|
||||||
/>
|
<option value='status'>Status</option>
|
||||||
) : (
|
<option value='role'>Role</option>
|
||||||
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
|
<option value='username'>Name</option>
|
||||||
<span className='text-white text-sm font-medium'>
|
</select>
|
||||||
{user.username.charAt(0).toUpperCase()}
|
<button
|
||||||
</span>
|
type='button'
|
||||||
</div>
|
onClick={() =>
|
||||||
)}
|
setSortDirection(prev =>
|
||||||
<div>
|
prev === 'asc' ? 'desc' : 'asc'
|
||||||
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
|
)
|
||||||
{user.username}
|
}
|
||||||
</div>
|
className='px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 dark:hover:bg-slate-600'
|
||||||
<div className='text-sm text-slate-500 dark:text-slate-400'>
|
aria-label={`Toggle sort direction. Currently ${
|
||||||
ID: {user._id.slice(-8)}
|
sortDirection === 'asc' ? 'ascending' : 'descending'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sortDirection === 'asc' ? 'Asc ↑' : 'Desc ↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<p className='text-center text-sm text-slate-600 dark:text-slate-300 py-6'>
|
||||||
|
No users match the current filters.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
||||||
|
<thead className='bg-slate-50 dark:bg-slate-600'>
|
||||||
|
<tr>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
||||||
|
{filteredUsers.map(user => (
|
||||||
|
<tr
|
||||||
|
key={user._id}
|
||||||
|
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||||
|
>
|
||||||
|
<td className='px-4 py-4'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.username}
|
||||||
|
className='w-8 h-8 rounded-full mr-3'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
|
||||||
|
<span className='text-white text-sm font-medium'>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
|
||||||
|
{user.username}
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
ID: {user._id.slice(-8)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
|
||||||
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
|
{user.email}
|
||||||
{user.email}
|
{user.emailVerified && (
|
||||||
{user.emailVerified && (
|
<span className='ml-2 text-green-600'>✓</span>
|
||||||
<span className='ml-2 text-green-600'>✓</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className='px-4 py-4'>
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
|
|
||||||
>
|
|
||||||
{user.status || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className='px-4 py-4'>
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
|
|
||||||
>
|
|
||||||
{user.role || 'USER'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
|
|
||||||
{user.createdAt
|
|
||||||
? new Date(user.createdAt).toLocaleDateString()
|
|
||||||
: 'Unknown'}
|
|
||||||
</td>
|
|
||||||
<td className='px-4 py-4'>
|
|
||||||
<div className='flex space-x-2'>
|
|
||||||
{user.status === AccountStatus.ACTIVE ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSuspendUser(user._id)}
|
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
|
||||||
disabled={user._id === currentUser?._id}
|
|
||||||
>
|
|
||||||
Suspend
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleActivateUser(user._id)}
|
|
||||||
className='text-green-600 hover:text-green-800 text-xs'
|
|
||||||
>
|
|
||||||
Activate
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
</td>
|
||||||
{user.password && (
|
<td className='px-4 py-4'>
|
||||||
<button
|
<span
|
||||||
onClick={() => setSelectedUser(user)}
|
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
|
||||||
className='text-blue-600 hover:text-blue-800 text-xs'
|
|
||||||
>
|
|
||||||
Change Password
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUser(user._id)}
|
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
|
||||||
disabled={
|
|
||||||
user._id === currentUser?._id ||
|
|
||||||
user.role === UserRole.ADMIN
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Delete
|
{user.status || 'Unknown'}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className='px-4 py-4'>
|
||||||
</tr>
|
<span
|
||||||
))}
|
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
|
||||||
</tbody>
|
>
|
||||||
</table>
|
{user.role || 'USER'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
{user.createdAt
|
||||||
|
? new Date(user.createdAt).toLocaleDateString()
|
||||||
|
: 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className='px-4 py-4'>
|
||||||
|
<div className='flex space-x-2'>
|
||||||
|
{user.status === AccountStatus.ACTIVE ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSuspendUser(user._id)}
|
||||||
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
|
disabled={user._id === currentUser?._id}
|
||||||
|
>
|
||||||
|
Suspend
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivateUser(user._id)}
|
||||||
|
className='text-green-600 hover:text-green-800 text-xs'
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.password && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedUser(user)}
|
||||||
|
className='text-blue-600 hover:text-blue-800 text-xs'
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user._id)}
|
||||||
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
|
disabled={
|
||||||
|
user._id === currentUser?._id ||
|
||||||
|
user.role === UserRole.ADMIN
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user