Fix jwt-token
This commit is contained in:
48
management/src/App.jsx
Normal file
48
management/src/App.jsx
Normal 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
|
||||
96
management/src/components/Layout.jsx
Normal file
96
management/src/components/Layout.jsx
Normal 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
|
||||
21
management/src/components/ProtectedRoute.jsx
Normal file
21
management/src/components/ProtectedRoute.jsx
Normal 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
|
||||
}
|
||||
82
management/src/contexts/AuthContext.jsx
Normal file
82
management/src/contexts/AuthContext.jsx
Normal 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
35
management/src/index.css
Normal 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
10
management/src/main.jsx
Normal 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>,
|
||||
)
|
||||
148
management/src/pages/Dashboard.jsx
Normal file
148
management/src/pages/Dashboard.jsx
Normal 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
|
||||
125
management/src/pages/Login.jsx
Normal file
125
management/src/pages/Login.jsx
Normal 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
|
||||
196
management/src/pages/System.jsx
Normal file
196
management/src/pages/System.jsx
Normal 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
|
||||
312
management/src/pages/Tenants.jsx
Normal file
312
management/src/pages/Tenants.jsx
Normal 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
|
||||
149
management/src/pages/Users.jsx
Normal file
149
management/src/pages/Users.jsx
Normal 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
|
||||
38
management/src/services/api.js
Normal file
38
management/src/services/api.js
Normal 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
|
||||
Reference in New Issue
Block a user