feat(admin): replace alerts with toasts

This commit is contained in:
William Valentin
2025-09-23 11:20:37 -07:00
parent e9a662d1e2
commit fcfe2a38e2

View File

@@ -15,12 +15,43 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
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();
}, []);
@@ -34,49 +65,64 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
} catch (error) {
setError('Failed to load users');
console.error('Error loading users:', error);
pushToast('Failed to load users', 'error');
} finally {
setLoading(false);
}
};
const handleSuspendUser = async (userId: string) => {
const handleSuspendUser = async (user: User) => {
try {
await databaseService.suspendUser(userId);
await databaseService.suspendUser(user._id);
pushToast(`${user.username} suspended`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to suspend user');
console.error('Error suspending user:', error);
pushToast('Failed to suspend user', 'error');
}
};
const handleActivateUser = async (userId: string) => {
const handleActivateUser = async (user: User) => {
try {
await databaseService.activateUser(userId);
await databaseService.activateUser(user._id);
pushToast(`${user.username} reactivated`, 'success');
await loadUsers();
} catch (error) {
setError('Failed to activate user');
console.error('Error activating user:', error);
pushToast('Failed to activate user', 'error');
}
};
const handleDeleteUser = async (userId: string) => {
if (
!confirm(
'Are you sure you want to delete this user? This action cannot be undone.'
)
) {
return;
}
const confirmDeleteUser = (user: User) => {
setUserPendingDeletion(user);
setError('');
};
const executeDeleteUser = async () => {
if (!userPendingDeletion) return;
setIsDeletingUser(true);
try {
await databaseService.deleteUser(userId);
await databaseService.deleteUser(userPendingDeletion._id);
pushToast(`${userPendingDeletion.username} deleted`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to delete user');
console.error('Error deleting user:', 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');
@@ -88,10 +134,11 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
setNewPassword('');
setSelectedUser(null);
setError('');
alert('Password changed successfully');
pushToast('Password changed successfully', 'success');
} catch (error) {
setError('Failed to change password');
console.error('Error changing password:', error);
pushToast('Failed to change password', 'error');
}
};
@@ -216,296 +263,346 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
}
return (
<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>
<>
<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={onClose}
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
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'
>
<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'
))}
</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'
>
Refresh
</button>
</div>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
</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
<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
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'
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 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>
<div className='flex space-x-3'>
<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'
}`}
onClick={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
{sortDirection === 'asc' ? 'Asc ' : 'Desc '}
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>
{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._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>
{/* 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>
{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={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
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'
>
Change Password
{isDeletingUser ? 'Deleting...' : 'Delete'}
</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'
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>
</div>
</div>
</>
);
};