Fix jwt-token
This commit is contained in:
@@ -7,6 +7,7 @@ import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Tenants from './pages/Tenants'
|
||||
import TenantUsersPage from './pages/TenantUsersPage'
|
||||
import Users from './pages/Users'
|
||||
import System from './pages/System'
|
||||
|
||||
@@ -25,6 +26,7 @@ function App() {
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="tenants" element={<Tenants />} />
|
||||
<Route path="tenants/:tenantId/users" element={<TenantUsersPage />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="system" element={<System />} />
|
||||
</Route>
|
||||
|
||||
353
management/src/components/UserModal.jsx
Normal file
353
management/src/components/UserModal.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { XMarkIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
const UserModal = ({ isOpen, onClose, onSave, user, tenant, title = "Create User" }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'viewer',
|
||||
phone: '',
|
||||
is_active: true
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
// Editing existing user
|
||||
setFormData({
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
password: '', // Never pre-fill passwords
|
||||
confirmPassword: '',
|
||||
role: user.role || 'viewer',
|
||||
phone: user.phone || '',
|
||||
is_active: user.is_active !== false
|
||||
})
|
||||
} else {
|
||||
// Creating new user
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'viewer',
|
||||
phone: '',
|
||||
is_active: true
|
||||
})
|
||||
}
|
||||
setErrors({})
|
||||
}, [user, isOpen])
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {}
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = 'Username is required'
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required'
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Email is invalid'
|
||||
}
|
||||
|
||||
if (!user) { // Only require password for new users
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Password is required'
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Password must be at least 6 characters'
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match'
|
||||
}
|
||||
} else if (formData.password) { // If editing and password provided
|
||||
if (formData.password.length < 6) {
|
||||
newErrors.password = 'Password must be at least 6 characters'
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match'
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// Prepare data for submission
|
||||
const submitData = { ...formData }
|
||||
delete submitData.confirmPassword
|
||||
|
||||
// Don't send empty password for updates
|
||||
if (user && !submitData.password) {
|
||||
delete submitData.password
|
||||
}
|
||||
|
||||
await onSave(submitData)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', error)
|
||||
setErrors({ general: error.message || 'Failed to save user' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}))
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{title} {tenant && `in ${tenant.name}`}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
disabled={!!user} // Can't change username for existing users
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter first name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Section */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password {!user && '*'}
|
||||
{user && <span className="text-gray-500 text-xs">(leave blank to keep current)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-10"
|
||||
placeholder={user ? "Enter new password" : "Enter password"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password {!user && '*'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-10"
|
||||
placeholder="Confirm password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role and Settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter phone number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Status */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">
|
||||
Active User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : (user ? 'Update User' : 'Create User')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserModal
|
||||
389
management/src/pages/TenantUsersPage.jsx
Normal file
389
management/src/pages/TenantUsersPage.jsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
ArrowLeftIcon,
|
||||
MagnifyingGlassIcon,
|
||||
UserIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import api from '../services/api'
|
||||
import toast from 'react-hot-toast'
|
||||
import UserModal from '../components/UserModal'
|
||||
|
||||
const TenantUsersPage = () => {
|
||||
const { tenantId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [tenant, setTenant] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedRole, setSelectedRole] = useState('')
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [tenantId, searchTerm, selectedRole])
|
||||
|
||||
const loadUsers = async (offset = 0) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
limit: pagination.limit.toString(),
|
||||
offset: offset.toString()
|
||||
})
|
||||
|
||||
if (searchTerm) params.append('search', searchTerm)
|
||||
if (selectedRole) params.append('role', selectedRole)
|
||||
|
||||
const response = await api.get(`/management/tenants/${tenantId}/users?${params}`)
|
||||
setUsers(response.data.data)
|
||||
setPagination(response.data.pagination)
|
||||
setTenant(response.data.tenant)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users')
|
||||
console.error('Error loading users:', error)
|
||||
|
||||
// If tenant not found, redirect back
|
||||
if (error.response?.status === 404) {
|
||||
navigate('/tenants')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
|
||||
const handleRoleFilter = (e) => {
|
||||
setSelectedRole(e.target.value)
|
||||
}
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setEditingUser(null)
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
const handleSaveUser = async (userData) => {
|
||||
try {
|
||||
if (editingUser) {
|
||||
// Update existing user
|
||||
await api.put(`/management/tenants/${tenantId}/users/${editingUser.id}`, userData)
|
||||
toast.success('User updated successfully')
|
||||
} else {
|
||||
// Create new user
|
||||
await api.post(`/management/tenants/${tenantId}/users`, userData)
|
||||
toast.success('User created successfully')
|
||||
}
|
||||
|
||||
setEditingUser(null)
|
||||
setShowUserModal(false)
|
||||
loadUsers() // Reload the list
|
||||
} catch (error) {
|
||||
console.error('Error saving user:', error)
|
||||
const message = error.response?.data?.message || 'Failed to save user'
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${user.username}"? This action cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/management/tenants/${tenantId}/users/${user.id}`)
|
||||
toast.success('User deleted successfully')
|
||||
loadUsers()
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Failed to delete user'
|
||||
toast.error(message)
|
||||
console.error('Error deleting user:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const colors = {
|
||||
admin: 'bg-red-100 text-red-800',
|
||||
operator: 'bg-blue-100 text-blue-800',
|
||||
viewer: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[role] || colors.viewer}`}>
|
||||
{role?.charAt(0).toUpperCase() + role?.slice(1)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadge = (isActive) => {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const handlePageChange = (newOffset) => {
|
||||
setPagination(prev => ({ ...prev, offset: newOffset }))
|
||||
loadUsers(newOffset)
|
||||
}
|
||||
|
||||
if (loading && !users.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading users...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/tenants')}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
Back to Tenants
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Users in {tenant?.name || 'Tenant'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage users and their roles within this tenant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search Users
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Search by name, username, or email..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Filter by Role
|
||||
</label>
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={handleRoleFilter}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-500">
|
||||
{pagination.total} total users
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<UserIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No users found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{searchTerm || selectedRole
|
||||
? 'Try adjusting your search filters.'
|
||||
: 'Get started by adding a user to this tenant.'
|
||||
}
|
||||
</p>
|
||||
{(!searchTerm && !selectedRole) && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add First User
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<div className="px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
|
||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{user.first_name || user.last_name
|
||||
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
||||
: user.username
|
||||
}
|
||||
</p>
|
||||
{getRoleBadge(user.role)}
|
||||
{getStatusBadge(user.is_active)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 mt-1">
|
||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
{user.phone && (
|
||||
<p className="text-sm text-gray-500">{user.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Created {new Date(user.created_at).toLocaleDateString()}
|
||||
{user.last_login && (
|
||||
<span> • Last login {new Date(user.last_login).toLocaleDateString()}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600"
|
||||
title="Edit user"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
className="p-2 text-gray-400 hover:text-red-600"
|
||||
title="Delete user"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(0, pagination.offset - pagination.limit))}
|
||||
disabled={pagination.offset === 0}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.offset + pagination.limit)}
|
||||
disabled={pagination.offset + pagination.limit >= pagination.total}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">{pagination.offset + 1}</span> to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(pagination.offset + pagination.limit, pagination.total)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium">{pagination.total}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(0, pagination.offset - pagination.limit))}
|
||||
disabled={pagination.offset === 0}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.offset + pagination.limit)}
|
||||
disabled={pagination.offset + pagination.limit >= pagination.total}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Modal */}
|
||||
<UserModal
|
||||
isOpen={showUserModal}
|
||||
onClose={() => {
|
||||
setShowUserModal(false)
|
||||
setEditingUser(null)
|
||||
}}
|
||||
onSave={handleSaveUser}
|
||||
user={editingUser}
|
||||
tenant={tenant}
|
||||
title={editingUser ? 'Edit User' : 'Create User'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TenantUsersPage
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import toast from 'react-hot-toast'
|
||||
import TenantModal from '../components/TenantModal'
|
||||
@@ -7,10 +8,12 @@ import {
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
BuildingOfficeIcon
|
||||
BuildingOfficeIcon,
|
||||
UserGroupIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
const Tenants = () => {
|
||||
const navigate = useNavigate()
|
||||
const [tenants, setTenants] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -93,6 +96,26 @@ const Tenants = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTenantStatus = async (tenant) => {
|
||||
const action = tenant.is_active ? 'deactivate' : 'activate'
|
||||
const confirmMessage = tenant.is_active
|
||||
? `Are you sure you want to deactivate "${tenant.name}"? Users will not be able to access this tenant.`
|
||||
: `Are you sure you want to activate "${tenant.name}"?`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/management/tenants/${tenant.id}/${action}`)
|
||||
toast.success(`Tenant ${action}d successfully`)
|
||||
loadTenants()
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${action} tenant`)
|
||||
console.error(`Error ${action}ing tenant:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTenant = async (tenant) => {
|
||||
try {
|
||||
// Fetch full tenant details for editing
|
||||
@@ -136,6 +159,18 @@ const Tenants = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusBadge = (isActive) => {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && tenants.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -200,6 +235,9 @@ const Tenants = () => {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
@@ -236,8 +274,24 @@ const Tenants = () => {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(tenant.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleTenantStatus(tenant)}
|
||||
className="cursor-pointer"
|
||||
title={`Click to ${tenant.is_active ? 'deactivate' : 'activate'}`}
|
||||
>
|
||||
{getStatusBadge(tenant.is_active)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => navigate(`/tenants/${tenant.id}/users`)}
|
||||
className="text-green-600 hover:text-green-900 p-1 rounded"
|
||||
title="Manage Users"
|
||||
>
|
||||
<UserGroupIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditTenant(tenant)}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded"
|
||||
|
||||
@@ -467,4 +467,323 @@ router.get('/system/info', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/management/tenants/:tenantId/users - Create user in specific tenant
|
||||
*/
|
||||
router.post('/tenants/:tenantId/users', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.params;
|
||||
const userData = req.body;
|
||||
|
||||
// Verify tenant exists
|
||||
const tenant = await Tenant.findByPk(tenantId);
|
||||
if (!tenant) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tenant not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists in this tenant
|
||||
const existingUser = await User.findOne({
|
||||
where: {
|
||||
username: userData.username,
|
||||
tenant_id: tenantId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'User already exists in this tenant'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hashedPassword = await bcrypt.hash(userData.password, 10);
|
||||
|
||||
// Create user with tenant association
|
||||
const user = await User.create({
|
||||
...userData,
|
||||
password: hashedPassword,
|
||||
tenant_id: tenantId,
|
||||
created_by: req.managementUser.username
|
||||
});
|
||||
|
||||
// Remove password from response
|
||||
const userResponse = user.toJSON();
|
||||
delete userResponse.password;
|
||||
|
||||
console.log(`Management: Admin ${req.managementUser.username} created user ${userData.username} in tenant ${tenant.name}`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: userResponse,
|
||||
message: 'User created successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error creating user:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/management/tenants/:tenantId/users/:userId - Update user in tenant
|
||||
*/
|
||||
router.put('/tenants/:tenantId/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { tenantId, userId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
tenant_id: tenantId
|
||||
},
|
||||
include: [{
|
||||
model: Tenant,
|
||||
as: 'tenant',
|
||||
attributes: ['name']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found in this tenant'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password if provided
|
||||
if (updates.password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
updates.password = await bcrypt.hash(updates.password, 10);
|
||||
}
|
||||
|
||||
await user.update(updates);
|
||||
|
||||
// Remove password from response
|
||||
const userResponse = user.toJSON();
|
||||
delete userResponse.password;
|
||||
|
||||
console.log(`Management: Admin ${req.managementUser.username} updated user ${user.username} in tenant ${user.tenant.name}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: userResponse,
|
||||
message: 'User updated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error updating user:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/management/tenants/:tenantId/users/:userId - Delete user from tenant
|
||||
*/
|
||||
router.delete('/tenants/:tenantId/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { tenantId, userId } = req.params;
|
||||
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
tenant_id: tenantId
|
||||
},
|
||||
include: [{
|
||||
model: Tenant,
|
||||
as: 'tenant',
|
||||
attributes: ['name']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'User not found in this tenant'
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin user
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = await User.count({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
role: 'admin'
|
||||
}
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Cannot delete the last admin user in tenant'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Management: Admin ${req.managementUser.username} deleting user ${user.username} from tenant ${user.tenant.name}`);
|
||||
|
||||
await user.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error deleting user:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete user',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/management/tenants/:tenantId/users - Get all users in a tenant
|
||||
*/
|
||||
router.get('/tenants/:tenantId/users', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.params;
|
||||
const { limit = 50, offset = 0, search, role } = req.query;
|
||||
|
||||
// Verify tenant exists
|
||||
const tenant = await Tenant.findByPk(tenantId);
|
||||
if (!tenant) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tenant not found'
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause = { tenant_id: tenantId };
|
||||
|
||||
if (search) {
|
||||
whereClause[Op.or] = [
|
||||
{ username: { [Op.iLike]: `%${search}%` } },
|
||||
{ email: { [Op.iLike]: `%${search}%` } },
|
||||
{ first_name: { [Op.iLike]: `%${search}%` } },
|
||||
{ last_name: { [Op.iLike]: `%${search}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
if (role) {
|
||||
whereClause.role = role;
|
||||
}
|
||||
|
||||
const users = await User.findAndCountAll({
|
||||
where: whereClause,
|
||||
attributes: { exclude: ['password'] },
|
||||
limit: Math.min(parseInt(limit), 100),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: users.rows,
|
||||
pagination: {
|
||||
total: users.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(users.count / parseInt(limit))
|
||||
},
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error fetching tenant users:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch tenant users',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/management/tenants/:tenantId/activate - Activate tenant
|
||||
*/
|
||||
router.post('/tenants/:tenantId/activate', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.params;
|
||||
|
||||
const tenant = await Tenant.findByPk(tenantId);
|
||||
if (!tenant) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tenant not found'
|
||||
});
|
||||
}
|
||||
|
||||
await tenant.update({ is_active: true });
|
||||
|
||||
console.log(`Management: Admin ${req.managementUser.username} activated tenant ${tenant.name}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tenant,
|
||||
message: 'Tenant activated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error activating tenant:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to activate tenant'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/management/tenants/:tenantId/deactivate - Deactivate tenant
|
||||
*/
|
||||
router.post('/tenants/:tenantId/deactivate', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.params;
|
||||
|
||||
const tenant = await Tenant.findByPk(tenantId);
|
||||
if (!tenant) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tenant not found'
|
||||
});
|
||||
}
|
||||
|
||||
await tenant.update({ is_active: false });
|
||||
|
||||
console.log(`Management: Admin ${req.managementUser.username} deactivated tenant ${tenant.name}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tenant,
|
||||
message: 'Tenant deactivated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Management: Error deactivating tenant:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to deactivate tenant'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user