fastcheck/src/pages/tenant/TenantUsers.tsx
2026-04-08 13:58:46 -04:00

247 lines
10 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTenant } from '../../contexts/TenantContext';
import toast from 'react-hot-toast';
import { PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import UserModal from '../../components/modals/UserModal';
import logger from '../../utils/logger';
interface User {
id: string;
name: string;
email: string;
role: 'superuser' | 'tenant_admin' | 'evaluator';
isActive: boolean;
}
const API_URL = globalThis.__APP_ENV__?.VITE_API_BASE_URL || 'https://duxiter.azurianlab.com/api';
const TenantUsers = () => {
const { tenant } = useTenant();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
// Fetch users
const fetchUsers = async () => {
try {
const token = localStorage.getItem('token');
const queryParams = new URLSearchParams();
if (search) queryParams.append('search', search);
if (roleFilter) queryParams.append('role', roleFilter);
if (statusFilter) queryParams.append('status', statusFilter);
const response = await fetch(`${API_URL}/users?${queryParams.toString()}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Failed to fetch users');
}
setUsers(data.users || []);
} catch (error) {
logger.error('Error fetching users:', error);
toast.error(error instanceof Error ? error.message : 'Failed to load users');
setUsers([]); // Ensure users is an array to prevent .map error
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, [search, roleFilter, statusFilter]);
// Toggle user status
const toggleUserStatus = async (userId: string) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_URL}/users/${userId}/toggle-status`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to update user status');
}
const data = await response.json();
setUsers(users.map(user =>
user.id === userId ? { ...user, isActive: data.isActive } : user
));
toast.success('User status updated successfully');
} catch (error) {
logger.error('Error updating user status:', error);
toast.error('Failed to update user status');
}
};
return (
<div className="px-4 sm:px-6 lg:px-8 text-gray-600 dark:text-gray-400">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Users</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Manage users in your organization
</p>
</div>
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
onClick={() => setShowAddModal(true)}
className="inline-flex items-center justify-center rounded-md border border-transparent bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 sm:w-auto"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
Add User
</button>
</div>
</div>
{/* Filters */}
<div className="mt-8 flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 pl-10 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
/>
</div>
</div>
<div className="sm:w-48">
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
>
<option value="">All Roles</option>
<option value="tenant_admin">Admin</option>
<option value="evaluator">Evaluator</option>
</select>
</div>
<div className="sm:w-48">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="block w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 focus:border-primary-500 focus:ring-primary-500 sm:text-sm text-gray-900 dark:text-white"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
{/* Users Table */}
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6">
Name
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
Email
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
Role
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white">
Status
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
{users.map((user) => (
<tr key={user.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-white sm:pl-6">
{user.name}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">{user.email}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-400">
{user.role === 'tenant_admin' ? 'Admin' : 'Evaluator'}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm">
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
user.isActive
? 'bg-green-900 text-green-200'
: 'bg-red-900 text-red-200'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button
onClick={() => {
setSelectedUser(user);
setShowEditModal(true);
}}
className="text-primary-500 hover:text-primary-400 mr-4"
>
Edit
</button>
<button
onClick={() => toggleUserStatus(user.id)}
className={`${
user.isActive
? 'text-red-500 hover:text-red-400'
: 'text-green-500 hover:text-green-400'
}`}
>
{user.isActive ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Add/Edit User Modal */}
<UserModal
isOpen={showAddModal || showEditModal}
onClose={() => {
setShowAddModal(false);
setShowEditModal(false);
setSelectedUser(null);
}}
user={selectedUser || undefined}
onSuccess={fetchUsers}
/>
</div>
);
};
export default TenantUsers;