611 lines
25 KiB
TypeScript
611 lines
25 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { User, UserRole } from '../../types';
|
||
import { AccountStatus } from '../../services/auth/auth.constants';
|
||
import { databaseService } from '../../services/database';
|
||
import { useUser } from '../../contexts/UserContext';
|
||
import { logger } from '../../services/logging';
|
||
|
||
interface AdminInterfaceProps {
|
||
onClose: () => void;
|
||
}
|
||
|
||
const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||
const { user: currentUser } = useUser();
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [userPendingDeletion, setUserPendingDeletion] = useState<User | null>(
|
||
null
|
||
);
|
||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||
const [toasts, setToasts] = useState<
|
||
Array<{ id: string; message: string; tone: 'success' | 'error' | 'info' }>
|
||
>([]);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [sortField, setSortField] = useState<
|
||
'createdAt' | 'status' | 'role' | 'username'
|
||
>('createdAt');
|
||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||
|
||
const createToastId = () =>
|
||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||
? crypto.randomUUID()
|
||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||
|
||
const removeToast = (id: string) => {
|
||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||
};
|
||
|
||
const pushToast = (
|
||
message: string,
|
||
tone: 'success' | 'error' | 'info' = 'info'
|
||
) => {
|
||
const id = createToastId();
|
||
setToasts(prev => [...prev, { id, message, tone }]);
|
||
window.setTimeout(() => removeToast(id), 4000);
|
||
};
|
||
|
||
const toastStyles: Record<'success' | 'error' | 'info', string> = {
|
||
success: 'bg-green-50 border border-green-200 text-green-800',
|
||
error: 'bg-red-50 border border-red-200 text-red-800',
|
||
info: 'bg-blue-50 border border-blue-200 text-blue-800',
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadUsers();
|
||
}, []);
|
||
|
||
const loadUsers = async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const users = await databaseService.getAllUsers();
|
||
setUsers(users);
|
||
} catch (error) {
|
||
setError('Failed to load users');
|
||
logger.ui.error('Error loading users', error as Error);
|
||
pushToast('Failed to load users', 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSuspendUser = async (user: User) => {
|
||
try {
|
||
await databaseService.suspendUser(user._id);
|
||
pushToast(`${user.username} suspended`, 'info');
|
||
await loadUsers();
|
||
} catch (error) {
|
||
setError('Failed to suspend user');
|
||
logger.ui.error('Error suspending user', error as Error);
|
||
pushToast('Failed to suspend user', 'error');
|
||
}
|
||
};
|
||
|
||
const handleActivateUser = async (user: User) => {
|
||
try {
|
||
await databaseService.activateUser(user._id);
|
||
pushToast(`${user.username} reactivated`, 'success');
|
||
await loadUsers();
|
||
} catch (error) {
|
||
setError('Failed to activate user');
|
||
logger.ui.error('Error activating user', error as Error);
|
||
pushToast('Failed to activate user', 'error');
|
||
}
|
||
};
|
||
|
||
const confirmDeleteUser = (user: User) => {
|
||
setUserPendingDeletion(user);
|
||
setError('');
|
||
};
|
||
|
||
const executeDeleteUser = async () => {
|
||
if (!userPendingDeletion) return;
|
||
|
||
setIsDeletingUser(true);
|
||
try {
|
||
await databaseService.deleteUser(userPendingDeletion._id);
|
||
pushToast(`${userPendingDeletion.username} deleted`, 'info');
|
||
await loadUsers();
|
||
} catch (error) {
|
||
setError('Failed to delete user');
|
||
logger.ui.error('Error deleting user', error as Error);
|
||
pushToast('Failed to delete user', 'error');
|
||
} finally {
|
||
setIsDeletingUser(false);
|
||
setUserPendingDeletion(null);
|
||
}
|
||
};
|
||
|
||
const closeDeleteDialog = () => {
|
||
if (isDeletingUser) return;
|
||
setUserPendingDeletion(null);
|
||
};
|
||
|
||
const handleChangePassword = async (userId: string) => {
|
||
if (!newPassword || newPassword.length < 6) {
|
||
setError('Password must be at least 6 characters long');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await databaseService.changeUserPassword(userId, newPassword);
|
||
setNewPassword('');
|
||
setSelectedUser(null);
|
||
setError('');
|
||
pushToast('Password changed successfully', 'success');
|
||
} catch (error) {
|
||
setError('Failed to change password');
|
||
logger.ui.error('Error changing password', error as Error);
|
||
pushToast('Failed to change password', 'error');
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status?: AccountStatus) => {
|
||
switch (status) {
|
||
case AccountStatus.ACTIVE:
|
||
return 'text-green-600 bg-green-100';
|
||
case AccountStatus.SUSPENDED:
|
||
return 'text-red-600 bg-red-100';
|
||
case AccountStatus.PENDING:
|
||
return 'text-yellow-600 bg-yellow-100';
|
||
default:
|
||
return 'text-gray-600 bg-gray-100';
|
||
}
|
||
};
|
||
|
||
const getRoleColor = (role?: UserRole) => {
|
||
return role === UserRole.ADMIN
|
||
? 'text-purple-600 bg-purple-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) {
|
||
return (
|
||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md'>
|
||
<h2 className='text-xl font-bold text-red-600 mb-4'>Access Denied</h2>
|
||
<p className='text-slate-600 dark:text-slate-300 mb-4'>
|
||
You don't have permission to access the admin interface.
|
||
</p>
|
||
<button
|
||
onClick={onClose}
|
||
className='w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className='fixed top-4 right-4 z-[70] space-y-2'>
|
||
{toasts.map(toast => (
|
||
<div
|
||
key={toast.id}
|
||
className={`px-4 py-3 rounded-lg shadow-lg flex items-start justify-between gap-3 ${toastStyles[toast.tone]}`}
|
||
role='status'
|
||
aria-live='polite'
|
||
>
|
||
<span className='text-sm font-medium'>{toast.message}</span>
|
||
<button
|
||
onClick={() => removeToast(toast.id)}
|
||
className='text-sm font-semibold text-slate-600 hover:text-slate-800 dark:text-slate-200 dark:hover:text-slate-50'
|
||
aria-label='Dismiss notification'
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
|
||
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
|
||
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
|
||
<div className='flex justify-between items-center'>
|
||
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
|
||
Admin Interface
|
||
</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||
>
|
||
<svg
|
||
className='w-6 h-6'
|
||
fill='none'
|
||
stroke='currentColor'
|
||
viewBox='0 0 24 24'
|
||
>
|
||
<path
|
||
strokeLinecap='round'
|
||
strokeLinejoin='round'
|
||
strokeWidth={2}
|
||
d='M6 18L18 6M6 6l12 12'
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
|
||
{error && (
|
||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className='text-center py-8'>
|
||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
|
||
<p className='mt-2 text-slate-600 dark:text-slate-300'>
|
||
Loading users...
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className='space-y-4'>
|
||
<div className='flex justify-between items-center'>
|
||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||
User Management ({visibleUsersLabel})
|
||
</h3>
|
||
<button
|
||
onClick={loadUsers}
|
||
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
|
||
>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
<div className='overflow-x-auto'>
|
||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-4'>
|
||
<div className='w-full sm:w-72'>
|
||
<label htmlFor='admin-search' className='sr-only'>
|
||
Search users
|
||
</label>
|
||
<input
|
||
id='admin-search'
|
||
type='search'
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
placeholder='Search by username or email'
|
||
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'
|
||
/>
|
||
</div>
|
||
<div className='flex items-center gap-2'>
|
||
<label
|
||
htmlFor='admin-sort'
|
||
className='text-sm text-slate-600 dark:text-slate-300'
|
||
>
|
||
Sort by
|
||
</label>
|
||
<select
|
||
id='admin-sort'
|
||
value={sortField}
|
||
onChange={e =>
|
||
setSortField(
|
||
e.target.value as
|
||
| 'createdAt'
|
||
| 'status'
|
||
| 'role'
|
||
| 'username'
|
||
)
|
||
}
|
||
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'
|
||
>
|
||
<option value='createdAt'>Created Date</option>
|
||
<option value='status'>Status</option>
|
||
<option value='role'>Role</option>
|
||
<option value='username'>Name</option>
|
||
</select>
|
||
<button
|
||
type='button'
|
||
onClick={() =>
|
||
setSortDirection(prev =>
|
||
prev === 'asc' ? 'desc' : 'asc'
|
||
)
|
||
}
|
||
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'
|
||
aria-label={`Toggle sort direction. Currently ${
|
||
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>
|
||
</td>
|
||
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
|
||
{user.email}
|
||
{user.emailVerified && (
|
||
<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)}
|
||
className='text-red-600 hover:text-red-800 text-xs'
|
||
disabled={user._id === currentUser?._id}
|
||
>
|
||
Suspend
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleActivateUser(user)}
|
||
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={() => confirmDeleteUser(user)}
|
||
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>
|
||
|
||
{/* Password Change Modal */}
|
||
{selectedUser && (
|
||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
|
||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
|
||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
|
||
Change Password for {selectedUser.username}
|
||
</h3>
|
||
<div className='space-y-4'>
|
||
<div>
|
||
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
|
||
New Password
|
||
</label>
|
||
<input
|
||
type='password'
|
||
value={newPassword}
|
||
onChange={e => setNewPassword(e.target.value)}
|
||
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
|
||
placeholder='Enter new password (min 6 characters)'
|
||
/>
|
||
</div>
|
||
<div className='flex space-x-3'>
|
||
<button
|
||
onClick={() => handleChangePassword(selectedUser._id)}
|
||
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
|
||
>
|
||
Change Password
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedUser(null);
|
||
setNewPassword('');
|
||
}}
|
||
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{userPendingDeletion && (
|
||
<div className='fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-[65] p-4'>
|
||
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full'>
|
||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-2'>
|
||
Delete {userPendingDeletion.username}?
|
||
</h3>
|
||
<p className='text-sm text-slate-600 dark:text-slate-300 mb-4'>
|
||
This action cannot be undone. The user's data will be
|
||
permanently removed.
|
||
</p>
|
||
<div className='flex space-x-3'>
|
||
<button
|
||
onClick={executeDeleteUser}
|
||
disabled={isDeletingUser}
|
||
className='flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md disabled:opacity-50'
|
||
>
|
||
{isDeletingUser ? 'Deleting...' : 'Delete'}
|
||
</button>
|
||
<button
|
||
onClick={closeDeleteDialog}
|
||
disabled={isDeletingUser}
|
||
className='flex-1 bg-slate-200 hover:bg-slate-300 text-slate-700 font-medium py-2 px-4 rounded-md dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 disabled:opacity-50'
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default AdminInterface;
|