Files
rxminder/components/admin/AdminInterface.tsx
2025-09-23 12:19:15 -07:00

611 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;