diff --git a/management/src/App.jsx b/management/src/App.jsx index 080be0a..d245cb9 100644 --- a/management/src/App.jsx +++ b/management/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/management/src/components/UserModal.jsx b/management/src/components/UserModal.jsx new file mode 100644 index 0000000..96c7c80 --- /dev/null +++ b/management/src/components/UserModal.jsx @@ -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 ( +
+
+
+

+ {title} {tenant && `in ${tenant.name}`} +

+ +
+ + {errors.general && ( +
+ {errors.general} +
+ )} + +
+ {/* Basic Information */} +
+
+ + + {errors.username && ( +

{errors.username}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+
+ +
+
+ + +
+ +
+ + +
+
+ + {/* Password Section */} +
+
+ +
+ + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+
+ + {/* Role and Settings */} +
+
+ + +
+ +
+ + +
+
+ + {/* Active Status */} +
+ + +
+ + {/* Form Actions */} +
+ + +
+
+
+
+ ) +} + +export default UserModal diff --git a/management/src/pages/TenantUsersPage.jsx b/management/src/pages/TenantUsersPage.jsx new file mode 100644 index 0000000..f52bd4d --- /dev/null +++ b/management/src/pages/TenantUsersPage.jsx @@ -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 ( + + {role?.charAt(0).toUpperCase() + role?.slice(1)} + + ) + } + + const getStatusBadge = (isActive) => { + return ( + + {isActive ? 'Active' : 'Inactive'} + + ) + } + + const handlePageChange = (newOffset) => { + setPagination(prev => ({ ...prev, offset: newOffset })) + loadUsers(newOffset) + } + + if (loading && !users.length) { + return ( +
+
Loading users...
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +
+

+ Users in {tenant?.name || 'Tenant'} +

+

+ Manage users and their roles within this tenant +

+
+
+ +
+ + {/* Filters */} +
+
+
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+
+ {pagination.total} total users +
+
+
+
+ + {/* Users Table */} +
+ {users.length === 0 ? ( +
+ +

No users found

+

+ {searchTerm || selectedRole + ? 'Try adjusting your search filters.' + : 'Get started by adding a user to this tenant.' + } +

+ {(!searchTerm && !selectedRole) && ( +
+ +
+ )} +
+ ) : ( + <> +
    + {users.map((user) => ( +
  • +
    +
    +
    +
    + +
    +
    +
    +
    +

    + {user.first_name || user.last_name + ? `${user.first_name || ''} ${user.last_name || ''}`.trim() + : user.username + } +

    + {getRoleBadge(user.role)} + {getStatusBadge(user.is_active)} +
    +
    +

    @{user.username}

    +

    {user.email}

    + {user.phone && ( +

    {user.phone}

    + )} +
    +

    + Created {new Date(user.created_at).toLocaleDateString()} + {user.last_login && ( + • Last login {new Date(user.last_login).toLocaleDateString()} + )} +

    +
    +
    +
    + + +
    +
    +
  • + ))} +
+ + {/* Pagination */} + {pagination.pages > 1 && ( +
+
+
+ + +
+
+
+

+ Showing {pagination.offset + 1} to{' '} + + {Math.min(pagination.offset + pagination.limit, pagination.total)} + {' '} + of {pagination.total} results +

+
+
+ +
+
+
+
+ )} + + )} +
+ + {/* User Modal */} + { + setShowUserModal(false) + setEditingUser(null) + }} + onSave={handleSaveUser} + user={editingUser} + tenant={tenant} + title={editingUser ? 'Edit User' : 'Create User'} + /> +
+ ) +} + +export default TenantUsersPage diff --git a/management/src/pages/Tenants.jsx b/management/src/pages/Tenants.jsx index 5767601..554ad29 100644 --- a/management/src/pages/Tenants.jsx +++ b/management/src/pages/Tenants.jsx @@ -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 ( + + {isActive ? 'Active' : 'Inactive'} + + ) + } + if (loading && tenants.length === 0) { return (
@@ -200,6 +235,9 @@ const Tenants = () => { Created + + Status + Actions @@ -236,8 +274,24 @@ const Tenants = () => { {new Date(tenant.created_at).toLocaleDateString()} + + +
+