feat(admin): replace alerts with toasts
This commit is contained in:
@@ -15,12 +15,43 @@ 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 [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 [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortField, setSortField] = useState<
|
const [sortField, setSortField] = useState<
|
||||||
'createdAt' | 'status' | 'role' | 'username'
|
'createdAt' | 'status' | 'role' | 'username'
|
||||||
>('createdAt');
|
>('createdAt');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
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(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,49 +65,64 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to load users');
|
setError('Failed to load users');
|
||||||
console.error('Error loading users:', error);
|
console.error('Error loading users:', error);
|
||||||
|
pushToast('Failed to load users', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuspendUser = async (userId: string) => {
|
const handleSuspendUser = async (user: User) => {
|
||||||
try {
|
try {
|
||||||
await databaseService.suspendUser(userId);
|
await databaseService.suspendUser(user._id);
|
||||||
|
pushToast(`${user.username} suspended`, 'info');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to suspend user');
|
setError('Failed to suspend user');
|
||||||
console.error('Error suspending user:', error);
|
console.error('Error suspending user:', error);
|
||||||
|
pushToast('Failed to suspend user', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActivateUser = async (userId: string) => {
|
const handleActivateUser = async (user: User) => {
|
||||||
try {
|
try {
|
||||||
await databaseService.activateUser(userId);
|
await databaseService.activateUser(user._id);
|
||||||
|
pushToast(`${user.username} reactivated`, 'success');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to activate user');
|
setError('Failed to activate user');
|
||||||
console.error('Error activating user:', error);
|
console.error('Error activating user:', error);
|
||||||
|
pushToast('Failed to activate user', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: string) => {
|
const confirmDeleteUser = (user: User) => {
|
||||||
if (
|
setUserPendingDeletion(user);
|
||||||
!confirm(
|
setError('');
|
||||||
'Are you sure you want to delete this user? This action cannot be undone.'
|
};
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const executeDeleteUser = async () => {
|
||||||
|
if (!userPendingDeletion) return;
|
||||||
|
|
||||||
|
setIsDeletingUser(true);
|
||||||
try {
|
try {
|
||||||
await databaseService.deleteUser(userId);
|
await databaseService.deleteUser(userPendingDeletion._id);
|
||||||
|
pushToast(`${userPendingDeletion.username} deleted`, 'info');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to delete user');
|
setError('Failed to delete user');
|
||||||
console.error('Error deleting user:', error);
|
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) => {
|
const handleChangePassword = async (userId: string) => {
|
||||||
if (!newPassword || newPassword.length < 6) {
|
if (!newPassword || newPassword.length < 6) {
|
||||||
setError('Password must be at least 6 characters long');
|
setError('Password must be at least 6 characters long');
|
||||||
@@ -88,10 +134,11 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setError('');
|
setError('');
|
||||||
alert('Password changed successfully');
|
pushToast('Password changed successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to change password');
|
setError('Failed to change password');
|
||||||
console.error('Error changing password:', error);
|
console.error('Error changing password:', error);
|
||||||
|
pushToast('Failed to change password', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,6 +263,26 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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='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='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='p-6 border-b border-slate-200 dark:border-slate-600'>
|
||||||
@@ -417,7 +484,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
<div className='flex space-x-2'>
|
<div className='flex space-x-2'>
|
||||||
{user.status === AccountStatus.ACTIVE ? (
|
{user.status === AccountStatus.ACTIVE ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSuspendUser(user._id)}
|
onClick={() => handleSuspendUser(user)}
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
disabled={user._id === currentUser?._id}
|
disabled={user._id === currentUser?._id}
|
||||||
>
|
>
|
||||||
@@ -425,7 +492,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivateUser(user._id)}
|
onClick={() => handleActivateUser(user)}
|
||||||
className='text-green-600 hover:text-green-800 text-xs'
|
className='text-green-600 hover:text-green-800 text-xs'
|
||||||
>
|
>
|
||||||
Activate
|
Activate
|
||||||
@@ -442,7 +509,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteUser(user._id)}
|
onClick={() => confirmDeleteUser(user)}
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
disabled={
|
disabled={
|
||||||
user._id === currentUser?._id ||
|
user._id === currentUser?._id ||
|
||||||
@@ -504,8 +571,38 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user