Initial commit: Complete NodeJS-native setup
- Migrated from Python pre-commit to NodeJS-native solution - Reorganized documentation structure - Set up Husky + lint-staged for efficient pre-commit hooks - Fixed Dockerfile healthcheck issue - Added comprehensive documentation index
This commit is contained in:
361
components/admin/AdminInterface.tsx
Normal file
361
components/admin/AdminInterface.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User, UserRole } from '../../types';
|
||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||
import { dbService } from '../../services/couchdb.factory';
|
||||
import { useUser } from '../../contexts/UserContext';
|
||||
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const allUsers = await dbService.getAllUsers();
|
||||
setUsers(allUsers);
|
||||
} catch (error) {
|
||||
setError('Failed to load users');
|
||||
console.error('Error loading users:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspendUser = async (userId: string) => {
|
||||
try {
|
||||
await dbService.suspendUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to suspend user');
|
||||
console.error('Error suspending user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateUser = async (userId: string) => {
|
||||
try {
|
||||
await dbService.activateUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to activate user');
|
||||
console.error('Error activating user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Are you sure you want to delete this user? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dbService.deleteUser(userId);
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
setError('Failed to delete user');
|
||||
console.error('Error deleting user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (userId: string) => {
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dbService.changeUserPassword(userId, newPassword);
|
||||
setNewPassword('');
|
||||
setSelectedUser(null);
|
||||
setError('');
|
||||
alert('Password changed successfully');
|
||||
} catch (error) {
|
||||
setError('Failed to change password');
|
||||
console.error('Error changing 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';
|
||||
};
|
||||
|
||||
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 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 ({users.length} users)
|
||||
</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'>
|
||||
<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'>
|
||||
{users.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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInterface;
|
||||
Reference in New Issue
Block a user