Files
vip-coordinator/frontend/src/components/UserManagement.tsx
kyle dc4655cef4 Backup: 2025-06-07 19:48 - Script test
[Restore from backup: vip-coordinator-backup-2025-06-07-19-48-script-test]
2026-01-24 09:33:58 +01:00

489 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { API_BASE_URL } from '../config/api';
interface User {
id: string;
email: string;
name: string;
picture: string;
role: string;
created_at: string;
last_sign_in_at?: string;
provider: string;
}
interface UserManagementProps {
currentUser: any;
}
const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
const [users, setUsers] = useState<User[]>([]);
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'all' | 'pending'>('all');
const [updatingUser, setUpdatingUser] = useState<string | null>(null);
// Check if current user is admin
if (currentUser?.role !== 'administrator') {
return (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-xl font-semibold text-red-800 mb-2">Access Denied</h2>
<p className="text-red-600">You need administrator privileges to access user management.</p>
</div>
);
}
const fetchUsers = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const userData = await response.json();
setUsers(userData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch users');
} finally {
setLoading(false);
}
};
const fetchPendingUsers = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/pending/list`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch pending users');
}
const pendingData = await response.json();
setPendingUsers(pendingData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch pending users');
}
};
const updateUserRole = async (userEmail: string, newRole: string) => {
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/role`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ role: newRole })
});
if (!response.ok) {
throw new Error('Failed to update user role');
}
// Refresh users list
await fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user role');
} finally {
setUpdatingUser(null);
}
};
const deleteUser = async (userEmail: string, userName: string) => {
if (!confirm(`Are you sure you want to delete user "${userName}"? This action cannot be undone.`)) {
return;
}
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
// Refresh users list
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
}
};
const approveUser = async (userEmail: string) => {
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'approved' })
});
if (!response.ok) {
throw new Error('Failed to approve user');
}
// Refresh both lists
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve user');
} finally {
setUpdatingUser(null);
}
};
const denyUser = async (userEmail: string, userName: string) => {
if (!confirm(`Are you sure you want to deny access for "${userName}"?`)) {
return;
}
setUpdatingUser(userEmail);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_BASE_URL}/auth/users/${userEmail}/approval`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'denied' })
});
if (!response.ok) {
throw new Error('Failed to deny user');
}
// Refresh both lists
await fetchUsers();
await fetchPendingUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to deny user');
} finally {
setUpdatingUser(null);
}
};
useEffect(() => {
fetchUsers();
fetchPendingUsers();
}, []);
useEffect(() => {
if (activeTab === 'pending') {
fetchPendingUsers();
}
}, [activeTab]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'administrator':
return 'bg-red-100 text-red-800 border-red-200';
case 'coordinator':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'driver':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded-lg w-1/4 mb-6"></div>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-gray-200 rounded-lg"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions (PostgreSQL Database)</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-500 hover:text-red-700"
>
Dismiss
</button>
</div>
)}
{/* Tab Navigation */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('all')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'all'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
👥 All Users ({users.length})
</button>
<button
onClick={() => setActiveTab('pending')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'pending'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Pending Approval ({pendingUsers.length})
{pendingUsers.length > 0 && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
{pendingUsers.length}
</span>
)}
</button>
</nav>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'all' && (
<div className="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
All Users ({users.length})
</h3>
</div>
<div className="divide-y divide-gray-200">
{users.map((user) => (
<div key={user.email} className="p-6 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{user.picture ? (
<img
src={user.picture}
alt={user.name}
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-gray-600 font-medium">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h4 className="text-lg font-medium text-gray-900">{user.name}</h4>
<p className="text-gray-600">{user.email}</p>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>Joined: {formatDate(user.created_at)}</span>
{user.last_sign_in_at && (
<span>Last login: {formatDate(user.last_sign_in_at)}</span>
)}
<span className="capitalize">via {user.provider}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Role:</span>
<select
value={user.role}
onChange={(e) => updateUserRole(user.email, e.target.value)}
disabled={updatingUser === user.email || user.email === currentUser.email}
className={`px-3 py-1 border rounded-md text-sm font-medium ${getRoleBadgeColor(user.role)} ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-opacity-80'
}`}
>
<option value="coordinator">Coordinator</option>
<option value="administrator">Administrator</option>
<option value="driver">Driver</option>
</select>
</div>
{user.email !== currentUser.email && (
<button
onClick={() => deleteUser(user.email, user.name)}
className="px-3 py-1 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md border border-red-200 transition-colors"
>
🗑 Delete
</button>
)}
{user.email === currentUser.email && (
<span className="px-3 py-1 text-sm text-blue-600 bg-blue-50 rounded-md border border-blue-200">
👤 You
</span>
)}
</div>
</div>
</div>
))}
</div>
{users.length === 0 && (
<div className="p-6 text-center text-gray-500">
No users found.
</div>
)}
</div>
)}
{/* Pending Users Tab */}
{activeTab === 'pending' && (
<div className="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-orange-50">
<h3 className="text-lg font-medium text-gray-900">
Pending Approval ({pendingUsers.length})
</h3>
<p className="text-sm text-gray-600 mt-1">
Users waiting for administrator approval to access the system
</p>
</div>
<div className="divide-y divide-gray-200">
{pendingUsers.map((user) => (
<div key={user.email} className="p-6 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{user.picture ? (
<img
src={user.picture}
alt={user.name}
className="w-12 h-12 rounded-full"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-gray-600 font-medium">
{user.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<h4 className="text-lg font-medium text-gray-900">{user.name}</h4>
<p className="text-gray-600">{user.email}</p>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>Requested: {formatDate(user.created_at)}</span>
<span className="capitalize">via {user.provider}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
getRoleBadgeColor(user.role)
}`}>
{user.role}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => approveUser(user.email)}
disabled={updatingUser === user.email}
className={`px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{updatingUser === user.email ? '⏳ Approving...' : '✅ Approve'}
</button>
<button
onClick={() => denyUser(user.email, user.name)}
disabled={updatingUser === user.email}
className={`px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md transition-colors ${
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{updatingUser === user.email ? '⏳ Denying...' : '❌ Deny'}
</button>
</div>
</div>
</div>
))}
</div>
{pendingUsers.length === 0 && (
<div className="p-6 text-center text-gray-500">
<div className="text-6xl mb-4"></div>
<p className="text-lg font-medium mb-2">No pending approvals</p>
<p className="text-sm">All users have been processed.</p>
</div>
)}
</div>
)}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">Role Descriptions:</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li><strong>Administrator:</strong> Full access to all features including user management</li>
<li><strong>Coordinator:</strong> Can manage VIPs, drivers, and schedules</li>
<li><strong>Driver:</strong> Can view assigned schedules and update status</li>
</ul>
</div>
<div className="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 className="font-medium text-orange-900 mb-2">🔐 User Approval System:</h4>
<p className="text-sm text-orange-800">
New users (except the first administrator) require approval before accessing the system.
Users with pending approval will see a "pending approval" message when they try to sign in.
</p>
</div>
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="font-medium text-green-900 mb-2"> PostgreSQL Database:</h4>
<p className="text-sm text-green-800">
User data is stored in your PostgreSQL database with proper indexing and relationships.
All user management operations are transactional and fully persistent across server restarts.
</p>
</div>
</div>
);
};
export default UserManagement;