Fix jwt-token

This commit is contained in:
2025-09-12 23:06:34 +02:00
parent f34cc187f2
commit c7f4f23f00
24 changed files with 1933 additions and 1 deletions

48
management/src/App.jsx Normal file
View File

@@ -0,0 +1,48 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { AuthProvider } from './contexts/AuthContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Tenants from './pages/Tenants'
import Users from './pages/Users'
import System from './pages/System'
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="tenants" element={<Tenants />} />
<Route path="users" element={<Users />} />
<Route path="system" element={<System />} />
</Route>
</Routes>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
</div>
</Router>
</AuthProvider>
)
}
export default App

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
HomeIcon,
BuildingOfficeIcon,
UsersIcon,
CogIcon,
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline'
const Layout = () => {
const { user, logout } = useAuth()
const location = useLocation()
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Tenants', href: '/tenants', icon: BuildingOfficeIcon },
{ name: 'Users', href: '/users', icon: UsersIcon },
{ name: 'System', href: '/system', icon: CogIcon },
]
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg">
<div className="flex h-16 items-center justify-center border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">UAMILS Management</h1>
</div>
<nav className="mt-8 px-4 space-y-2">
{navigation.map((item) => {
const isActive = location.pathname === item.href
return (
<NavLink
key={item.name}
to={item.href}
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon
className={`mr-3 h-5 w-5 ${
isActive ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
}`}
/>
{item.name}
</NavLink>
)
})}
</nav>
{/* User info and logout */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user?.username?.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{user?.username}
</p>
<p className="text-xs text-gray-500">
{user?.role}
</p>
</div>
</div>
<button
onClick={logout}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
title="Logout"
>
<ArrowRightOnRectangleIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="pl-64">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet />
</div>
</div>
</div>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,21 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading, isAdmin } = useAuth()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div>
</div>
)
}
if (!isAuthenticated || !isAdmin) {
return <Navigate to="/login" replace />
}
return children
}

View File

@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Check for existing token on app start
const token = localStorage.getItem('management_token')
const savedUser = localStorage.getItem('management_user')
if (token && savedUser) {
try {
setUser(JSON.parse(savedUser))
} catch (error) {
console.error('Error parsing saved user:', error)
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
}
}
setLoading(false)
}, [])
const login = async (username, password) => {
try {
const response = await api.post('/users/login', {
username,
password
})
const { token, user: userData } = response.data.data
// Check if user is admin
if (userData.role !== 'admin') {
throw new Error('Access denied. Admin privileges required.')
}
localStorage.setItem('management_token', token)
localStorage.setItem('management_user', JSON.stringify(userData))
setUser(userData)
toast.success('Login successful')
return { success: true }
} catch (error) {
const message = error.response?.data?.message || error.message || 'Login failed'
toast.error(message)
return { success: false, message }
}
}
const logout = () => {
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
setUser(null)
toast.success('Logged out successfully')
}
const value = {
user,
loading,
login,
logout,
isAuthenticated: !!user,
isAdmin: user?.role === 'admin'
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export default AuthContext

35
management/src/index.css Normal file
View File

@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

10
management/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import { BuildingOfficeIcon, UsersIcon, ServerIcon, ChartBarIcon } from '@heroicons/react/24/outline'
const Dashboard = () => {
const [stats, setStats] = useState({
tenants: 0,
users: 0,
activeSessions: 0,
systemHealth: 'good'
})
const [loading, setLoading] = useState(true)
useEffect(() => {
loadDashboardData()
}, [])
const loadDashboardData = async () => {
try {
// Load basic stats
const [tenantsRes, usersRes] = await Promise.all([
api.get('/tenants?limit=1'),
api.get('/users?limit=1')
])
setStats({
tenants: tenantsRes.data.pagination?.total || 0,
users: usersRes.data.pagination?.total || 0,
activeSessions: Math.floor(Math.random() * 50) + 10, // Mock data
systemHealth: 'good'
})
} catch (error) {
console.error('Error loading dashboard data:', error)
} finally {
setLoading(false)
}
}
const statCards = [
{
name: 'Total Tenants',
value: stats.tenants,
icon: BuildingOfficeIcon,
color: 'bg-blue-500'
},
{
name: 'Total Users',
value: stats.users,
icon: UsersIcon,
color: 'bg-green-500'
},
{
name: 'Active Sessions',
value: stats.activeSessions,
icon: ChartBarIcon,
color: 'bg-yellow-500'
},
{
name: 'System Health',
value: stats.systemHealth === 'good' ? 'Good' : 'Issues',
icon: ServerIcon,
color: stats.systemHealth === 'good' ? 'bg-green-500' : 'bg-red-500'
}
]
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Overview of your UAMILS system</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<div key={stat.name} className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${stat.color}`}>
<stat.icon className="h-6 w-6 text-white" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">{stat.name}</p>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
</div>
</div>
</div>
))}
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h3>
<div className="space-y-3">
<button className="w-full text-left px-4 py-3 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
<div className="font-medium text-blue-900">Create New Tenant</div>
<div className="text-sm text-blue-700">Add a new organization to the system</div>
</button>
<button className="w-full text-left px-4 py-3 bg-green-50 hover:bg-green-100 rounded-lg transition-colors">
<div className="font-medium text-green-900">Manage Users</div>
<div className="text-sm text-green-700">View and edit user accounts</div>
</button>
<button className="w-full text-left px-4 py-3 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors">
<div className="font-medium text-purple-900">System Settings</div>
<div className="text-sm text-purple-700">Configure system-wide settings</div>
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3 text-sm">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-gray-600">New tenant "Acme Corp" created</span>
<span className="text-gray-400">2 hours ago</span>
</div>
<div className="flex items-center space-x-3 text-sm">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-gray-600">User "john.doe" logged in</span>
<span className="text-gray-400">4 hours ago</span>
</div>
<div className="flex items-center space-x-3 text-sm">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<span className="text-gray-600">System backup completed</span>
<span className="text-gray-400">6 hours ago</span>
</div>
<div className="flex items-center space-x-3 text-sm">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span className="text-gray-600">SAML configuration updated</span>
<span className="text-gray-400">1 day ago</span>
</div>
</div>
</div>
</div>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,125 @@
import React, { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
const Login = () => {
const { isAuthenticated, login } = useAuth()
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
const result = await login(formData.username, formData.password)
if (!result.success) {
setLoading(false)
}
// If successful, the redirect will happen automatically
}
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
UAMILS Management Portal
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to manage tenants and system configuration
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={formData.username}
onChange={handleInputChange}
disabled={loading}
/>
</div>
<div className="relative">
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={formData.password}
onChange={handleInputChange}
disabled={loading}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent 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 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Signing in...
</div>
) : (
'Sign in'
)}
</button>
</div>
<div className="text-center">
<p className="text-xs text-gray-500">
Admin access required. Default: admin / admin123
</p>
</div>
</form>
</div>
</div>
)
}
export default Login

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import {
CogIcon,
ServerIcon,
DatabaseIcon,
ShieldCheckIcon,
ClockIcon
} from '@heroicons/react/24/outline'
const System = () => {
const [systemInfo, setSystemInfo] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadSystemInfo()
}, [])
const loadSystemInfo = async () => {
try {
setLoading(true)
// This would be a real API endpoint in production
const response = await api.get('/system/info')
setSystemInfo(response.data.data)
} catch (error) {
// Mock data for development
setSystemInfo({
version: '1.0.0',
environment: 'development',
uptime: '7d 14h 32m',
database: {
status: 'connected',
version: 'PostgreSQL 14.2',
connections: 5,
maxConnections: 100
},
memory: {
used: '256MB',
total: '1GB',
percentage: 25
},
lastBackup: '2024-01-15T10:30:00Z',
ssl: {
status: 'valid',
expiresAt: '2024-03-15T00:00:00Z'
}
})
} finally {
setLoading(false)
}
}
const StatusCard = ({ title, icon: Icon, children }) => (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center mb-4">
<Icon className="h-6 w-6 text-blue-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
</div>
{children}
</div>
)
const StatusIndicator = ({ status }) => {
const colors = {
connected: 'bg-green-100 text-green-800',
valid: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800'
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.error}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">System</h1>
<p className="text-gray-600">Monitor system health and configuration</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<StatusCard title="Server Status" icon={ServerIcon}>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Version</span>
<span className="text-sm font-medium">{systemInfo.version}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Environment</span>
<span className="text-sm font-medium capitalize">{systemInfo.environment}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Uptime</span>
<span className="text-sm font-medium">{systemInfo.uptime}</span>
</div>
</div>
</StatusCard>
<StatusCard title="Database" icon={DatabaseIcon}>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Status</span>
<StatusIndicator status={systemInfo.database.status} />
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Version</span>
<span className="text-sm font-medium">{systemInfo.database.version}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Connections</span>
<span className="text-sm font-medium">
{systemInfo.database.connections}/{systemInfo.database.maxConnections}
</span>
</div>
</div>
</StatusCard>
<StatusCard title="SSL Certificate" icon={ShieldCheckIcon}>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Status</span>
<StatusIndicator status={systemInfo.ssl.status} />
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Expires</span>
<span className="text-sm font-medium">
{new Date(systemInfo.ssl.expiresAt).toLocaleDateString()}
</span>
</div>
</div>
</StatusCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<CogIcon className="h-5 w-5 text-blue-600 mr-2" />
Memory Usage
</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Used</span>
<span className="font-medium">{systemInfo.memory.used}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Total</span>
<span className="font-medium">{systemInfo.memory.total}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${systemInfo.memory.percentage}%` }}
></div>
</div>
<div className="text-xs text-gray-500 text-center">
{systemInfo.memory.percentage}% used
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
<ClockIcon className="h-5 w-5 text-blue-600 mr-2" />
Last Backup
</h3>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{new Date(systemInfo.lastBackup).toLocaleDateString()}
</div>
<div className="text-sm text-gray-500">
{new Date(systemInfo.lastBackup).toLocaleTimeString()}
</div>
<button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
Run Backup Now
</button>
</div>
</div>
</div>
</div>
)
}
export default System

View File

@@ -0,0 +1,312 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import {
PlusIcon,
PencilIcon,
TrashIcon,
MagnifyingGlassIcon,
BuildingOfficeIcon
} from '@heroicons/react/24/outline'
const Tenants = () => {
const [tenants, setTenants] = useState([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingTenant, setEditingTenant] = useState(null)
const [pagination, setPagination] = useState({
total: 0,
limit: 10,
offset: 0,
pages: 0
})
useEffect(() => {
loadTenants()
}, [])
const loadTenants = async (offset = 0, search = '') => {
try {
setLoading(true)
const params = new URLSearchParams({
limit: pagination.limit.toString(),
offset: offset.toString()
})
if (search) {
params.append('search', search)
}
const response = await api.get(`/tenants?${params}`)
setTenants(response.data.data)
setPagination(response.data.pagination)
} catch (error) {
toast.error('Failed to load tenants')
console.error('Error loading tenants:', error)
} finally {
setLoading(false)
}
}
const handleSearch = (e) => {
const term = e.target.value
setSearchTerm(term)
loadTenants(0, term)
}
const deleteTenant = async (tenantId) => {
if (!confirm('Are you sure you want to delete this tenant? This action cannot be undone.')) {
return
}
try {
await api.delete(`/tenants/${tenantId}`)
toast.success('Tenant deleted successfully')
loadTenants()
} catch (error) {
toast.error('Failed to delete tenant')
console.error('Error deleting tenant:', error)
}
}
const getAuthProviderBadge = (provider) => {
const colors = {
local: 'bg-gray-100 text-gray-800',
saml: 'bg-blue-100 text-blue-800',
oauth: 'bg-green-100 text-green-800',
ldap: 'bg-yellow-100 text-yellow-800',
custom_sso: 'bg-purple-100 text-purple-800'
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[provider] || colors.local}`}>
{provider.toUpperCase()}
</span>
)
}
const getSubscriptionBadge = (type) => {
const colors = {
free: 'bg-gray-100 text-gray-800',
basic: 'bg-blue-100 text-blue-800',
premium: 'bg-purple-100 text-purple-800',
enterprise: 'bg-green-100 text-green-800'
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[type] || colors.basic}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
)
}
if (loading && tenants.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div>
{/* Header */}
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
<p className="text-gray-600">Manage organizations and their configurations</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2"
>
<PlusIcon className="h-5 w-5" />
<span>Create Tenant</span>
</button>
</div>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search tenants..."
value={searchTerm}
onChange={handleSearch}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg w-full max-w-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Tenants Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tenant
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Domain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Auth Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Subscription
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Users
</th>
<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-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center">
<BuildingOfficeIcon className="h-6 w-6 text-blue-600" />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{tenant.name}</div>
<div className="text-sm text-gray-500">{tenant.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{tenant.domain || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getAuthProviderBadge(tenant.auth_provider)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getSubscriptionBadge(tenant.subscription_type)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{tenant.users?.length || 0}
</td>
<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 text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => setEditingTenant(tenant)}
className="text-blue-600 hover:text-blue-900 p-1 rounded"
title="Edit"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => deleteTenant(tenant.id)}
className="text-red-600 hover:text-red-900 p-1 rounded"
title="Delete"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{pagination.pages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => loadTenants(Math.max(0, pagination.offset - pagination.limit), searchTerm)}
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={() => loadTenants(pagination.offset + pagination.limit, searchTerm)}
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>
</div>
)}
{tenants.length === 0 && !loading && (
<div className="text-center py-12">
<BuildingOfficeIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No tenants</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new tenant.</p>
<div className="mt-6">
<button
onClick={() => setShowCreateModal(true)}
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"
>
<PlusIcon className="h-5 w-5 mr-2" />
Create Tenant
</button>
</div>
</div>
)}
</div>
{/* Modals would go here */}
{showCreateModal && (
<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-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">Create New Tenant</h3>
<p className="text-sm text-gray-500 mb-4">
Tenant creation modal would go here with form fields for name, slug, domain, auth provider, etc.
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={() => {
setShowCreateModal(false)
toast.success('Tenant creation modal - implement form handling')
}}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default Tenants

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react'
import api from '../services/api'
import toast from 'react-hot-toast'
import { UsersIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
const Users = () => {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
loadUsers()
}, [])
const loadUsers = async () => {
try {
setLoading(true)
const response = await api.get('/users')
setUsers(response.data.data || [])
} catch (error) {
toast.error('Failed to load users')
console.error('Error loading users:', error)
} finally {
setLoading(false)
}
}
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 filteredUsers = users.filter(user =>
user.username?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email?.toLowerCase().includes(searchTerm.toLowerCase())
)
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<p className="text-gray-600">Manage user accounts across all tenants</p>
</div>
<div className="mb-6">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg w-full max-w-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-sm font-medium text-blue-600">
{user.username?.charAt(0).toUpperCase()}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.username}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getRoleBadge(user.role)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="text-center py-12">
<UsersIcon 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 ? 'Try adjusting your search criteria.' : 'No users have been created yet.'}
</p>
</div>
)}
</div>
</div>
)
}
export default Users

View File

@@ -0,0 +1,38 @@
import axios from 'axios'
// Create axios instance with base configuration
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('management_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('management_token')
localStorage.removeItem('management_user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api