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

243
deploy-management.sh Normal file
View File

@@ -0,0 +1,243 @@
#!/bin/bash
# UAMILS Management Portal Deployment Script
# Complete setup for management.dev.uggla.uamils.com
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DOMAIN="${DOMAIN:-dev.uggla.uamils.com}"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
check_prerequisites() {
log "Checking prerequisites..."
# Check if running as root
if [[ $EUID -ne 0 ]]; then
log "ERROR: This script must be run as root (use sudo)"
exit 1
fi
# Check docker
if ! command -v docker >/dev/null 2>&1; then
log "ERROR: Docker is not installed"
exit 1
fi
# Check docker-compose
if ! command -v docker-compose >/dev/null 2>&1; then
log "ERROR: Docker Compose is not installed"
exit 1
fi
# Check nginx
if ! command -v nginx >/dev/null 2>&1; then
log "ERROR: Nginx is not installed"
exit 1
fi
log "✅ Prerequisites check passed"
}
setup_ssl() {
log "Setting up SSL certificates..."
cd "$PROJECT_ROOT/ssl"
# Ensure certificates exist
if [[ ! -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]]; then
log "Getting SSL certificates..."
./certbot-manager.sh renew
else
log "SSL certificates already exist"
fi
log "✅ SSL certificates ready"
}
build_containers() {
log "Building Docker containers..."
cd "$PROJECT_ROOT"
# Build all containers including management
docker-compose build backend frontend management
log "✅ Docker containers built"
}
start_services() {
log "Starting services..."
cd "$PROJECT_ROOT"
# Start database and cache first
docker-compose up -d postgres redis
# Wait for database to be ready
log "Waiting for database to be ready..."
sleep 10
# Start application services
docker-compose up -d backend frontend management
log "✅ Services started"
}
configure_nginx() {
log "Configuring nginx..."
cd "$PROJECT_ROOT/ssl"
# Run nginx SSL setup
./nginx-ssl-setup.sh setup
log "✅ Nginx configured"
}
verify_deployment() {
log "Verifying deployment..."
# Check container health
cd "$PROJECT_ROOT"
log "Checking container status..."
docker-compose ps
# Test endpoints
log "Testing endpoints..."
# Main site
if curl -sSf "https://$DOMAIN/api/health" >/dev/null 2>&1; then
log "✅ Main site API accessible"
else
log "⚠️ Main site API not responding"
fi
# Management portal
if curl -sSf "https://management.$DOMAIN" >/dev/null 2>&1; then
log "✅ Management portal accessible"
else
log "⚠️ Management portal not responding"
fi
log "✅ Deployment verification complete"
}
show_results() {
log "Deployment Complete! 🎉"
echo ""
echo "=========================================="
echo "UAMILS Management Portal Deployment"
echo "=========================================="
echo ""
echo "🌐 Main Application:"
echo " https://$DOMAIN"
echo ""
echo "🛠️ Management Portal:"
echo " https://management.$DOMAIN"
echo ""
echo "🔐 Multi-tenant Sites:"
echo " https://tenant1.$DOMAIN"
echo " https://tenant2.$DOMAIN"
echo ""
echo "🔗 API Endpoints:"
echo " https://$DOMAIN/api/health"
echo " https://$DOMAIN/api/"
echo ""
echo "📊 Docker Services:"
echo " - Backend API (port 3002)"
echo " - Frontend App (port 3001)"
echo " - Management Portal (port 3003)"
echo " - PostgreSQL Database (port 5433)"
echo " - Redis Cache (port 6380)"
echo ""
echo "🔒 Security Features:"
echo " ✅ SSL/TLS Encryption"
echo " ✅ Auto-renewal SSL Certificates"
echo " ✅ Security Headers"
echo " ✅ Admin-only Management Access"
echo ""
echo "📝 Management Portal Features:"
echo " - System Dashboard"
echo " - Tenant Management"
echo " - User Administration"
echo " - System Monitoring"
echo ""
echo "🔄 Management Commands:"
echo " cd $PROJECT_ROOT/management && ./build.sh [command]"
echo " cd $PROJECT_ROOT/ssl && ./nginx-ssl-setup.sh [command]"
echo " cd $PROJECT_ROOT && docker-compose [command]"
echo ""
echo "=========================================="
}
# Main execution
main() {
log "Starting UAMILS Management Portal deployment"
check_prerequisites
setup_ssl
build_containers
start_services
configure_nginx
verify_deployment
show_results
}
# Handle command line arguments
case "${1:-deploy}" in
"deploy")
main
;;
"ssl")
setup_ssl
configure_nginx
;;
"build")
build_containers
;;
"start")
start_services
;;
"stop")
cd "$PROJECT_ROOT"
docker-compose down
;;
"restart")
cd "$PROJECT_ROOT"
docker-compose restart
;;
"logs")
cd "$PROJECT_ROOT"
docker-compose logs -f
;;
"status")
cd "$PROJECT_ROOT"
docker-compose ps
;;
*)
echo "UAMILS Management Portal Deployment"
echo "==================================="
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " deploy Full deployment (default)"
echo " ssl Setup SSL and nginx only"
echo " build Build Docker containers"
echo " start Start services"
echo " stop Stop all services"
echo " restart Restart all services"
echo " logs Show service logs"
echo " status Show service status"
echo ""
echo "Environment variables:"
echo " DOMAIN Domain name (default: dev.uggla.uamils.com)"
echo ""
;;
esac

View File

@@ -112,6 +112,27 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
# Management Portal
management:
build:
context: ./management
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-/api}
container_name: drone-detection-management
restart: unless-stopped
ports:
- "3003:80"
networks:
- drone-network
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
# Nginx Reverse Proxy (Production) # Nginx Reverse Proxy (Production)
nginx: nginx:
image: nginx:alpine image: nginx:alpine

35
management/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
FROM node:18-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build arguments
ARG VITE_API_URL=/api
# Set environment variables
ENV VITE_API_URL=$VITE_API_URL
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

69
management/README.md Normal file
View File

@@ -0,0 +1,69 @@
# Management Portal
A dedicated administration interface for the UAMILS drone detection system.
## Overview
The Management Portal provides a clean, separate interface for system administrators to manage tenants, users, and system configuration. It runs as a dedicated subdomain at `management.dev.uggla.uamils.com`.
## Features
- **Dashboard**: System overview with statistics and health monitoring
- **Tenant Management**: Create, edit, and manage tenant organizations
- **User Administration**: View and manage user accounts across all tenants
- **System Monitoring**: Monitor system health, SSL certificates, and backups
- **Secure Access**: Admin-only authentication with role-based access control
## Technology Stack
- **Frontend**: React 18 + Vite
- **Styling**: Tailwind CSS
- **Icons**: Heroicons
- **Notifications**: React Hot Toast
- **HTTP Client**: Axios
- **Routing**: React Router DOM
## Development
### Prerequisites
- Node.js 18+
- Docker and Docker Compose
### Local Development
1. Install dependencies:
```bash
npm install
```
2. Start development server:
```bash
npm run dev
```
3. Open http://localhost:5173
### Production Build
1. Build with Docker:
```bash
./build.sh build
```
2. Start production container:
```bash
./build.sh start
```
## Docker Deployment
The management portal is integrated into the main docker-compose setup and serves at port 3003.
## Nginx Configuration
The management portal is served via nginx at `management.dev.uggla.uamils.com`. The SSL setup script automatically configures the subdomain routing.
## Authentication
The management portal requires admin-level authentication. Users must have the `admin` role to access any management features.

97
management/build.sh Normal file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
# Management Portal Build Script
# Builds and starts the management portal for tenant administration
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
build_management() {
log "Building management portal..."
cd "$PROJECT_ROOT"
# Build management portal with docker-compose
docker-compose build management
log "✅ Management portal built successfully"
}
start_management() {
log "Starting management portal..."
cd "$PROJECT_ROOT"
# Start management portal
docker-compose up -d management
log "✅ Management portal started"
log "🌐 Management portal available at: http://localhost:3003"
}
stop_management() {
log "Stopping management portal..."
cd "$PROJECT_ROOT"
# Stop management portal
docker-compose down management
log "✅ Management portal stopped"
}
logs_management() {
log "Showing management portal logs..."
cd "$PROJECT_ROOT"
# Show logs
docker-compose logs -f management
}
# Handle command line arguments
case "${1:-build}" in
"build")
build_management
;;
"start")
build_management
start_management
;;
"stop")
stop_management
;;
"restart")
stop_management
build_management
start_management
;;
"logs")
logs_management
;;
"status")
cd "$PROJECT_ROOT"
docker-compose ps management
;;
*)
echo "Management Portal Build Script"
echo "============================="
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " build Build management portal (default)"
echo " start Build and start management portal"
echo " stop Stop management portal"
echo " restart Restart management portal"
echo " logs Show management portal logs"
echo " status Show management portal status"
echo ""
;;
esac

13
management/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UAMILS Management Portal</title>
<link rel="icon" type="image/svg+xml" href="/management-icon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

36
management/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

37
management/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "uamils-management-portal",
"version": "1.0.0",
"description": "Tenant Management Portal for UAMILS Drone Detection System",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"axios": "^1.5.0",
"lucide-react": "^0.263.1",
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"vite": "^4.4.5"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
}
}
},
},
plugins: [],
}

20
management/vite.config.js Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/',
server: {
port: 3100,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
})

View File

@@ -15,6 +15,7 @@ CONFIG_NAME="$DOMAIN"
# Backend and frontend ports (from docker-compose) # Backend and frontend ports (from docker-compose)
BACKEND_PORT="${BACKEND_PORT:-3002}" BACKEND_PORT="${BACKEND_PORT:-3002}"
FRONTEND_PORT="${FRONTEND_PORT:-3001}" FRONTEND_PORT="${FRONTEND_PORT:-3001}"
MANAGEMENT_PORT="${MANAGEMENT_PORT:-3003}"
log() { log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
@@ -160,6 +161,75 @@ server {
} }
} }
# HTTPS server for management subdomain
server {
listen 443 ssl http2;
server_name management.$DOMAIN;
# SSL Configuration
ssl_certificate $CERT_PATH/fullchain.pem;
ssl_certificate_key $CERT_PATH/privkey.pem;
# SSL Security Settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Management portal - serve from management container
location / {
proxy_pass http://127.0.0.1:$MANAGEMENT_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# API routes - proxy to backend (management uses same API)
location /api/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# Static files for management portal
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
proxy_pass http://127.0.0.1:$MANAGEMENT_PORT;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# HTTPS server for wildcard subdomains (multi-tenant) # HTTPS server for wildcard subdomains (multi-tenant)
server { server {
listen 443 ssl http2; listen 443 ssl http2;
@@ -291,7 +361,10 @@ show_results() {
echo "🌐 Your site is now available at:" echo "🌐 Your site is now available at:"
echo " https://$DOMAIN" echo " https://$DOMAIN"
echo "" echo ""
echo "🔐 Multi-tenant subdomains:" echo "<EFBFBD> Management portal:"
echo " https://management.$DOMAIN"
echo ""
echo "<22>🔐 Multi-tenant subdomains:"
echo " https://tenant1.$DOMAIN" echo " https://tenant1.$DOMAIN"
echo " https://tenant2.$DOMAIN" echo " https://tenant2.$DOMAIN"
echo " https://any-name.$DOMAIN" echo " https://any-name.$DOMAIN"
@@ -306,6 +379,7 @@ show_results() {
echo " ✅ Security headers" echo " ✅ Security headers"
echo " ✅ Wildcard certificate support" echo " ✅ Wildcard certificate support"
echo " ✅ Multi-tenant routing" echo " ✅ Multi-tenant routing"
echo " ✅ Dedicated management portal"
echo "" echo ""
echo "📝 Configuration file:" echo "📝 Configuration file:"
echo " $SITES_AVAILABLE/$CONFIG_NAME" echo " $SITES_AVAILABLE/$CONFIG_NAME"
@@ -356,5 +430,6 @@ case "${1:-setup}" in
echo " DOMAIN Domain name (default: dev.uggla.uamils.com)" echo " DOMAIN Domain name (default: dev.uggla.uamils.com)"
echo " BACKEND_PORT Backend port (default: 3002)" echo " BACKEND_PORT Backend port (default: 3002)"
echo " FRONTEND_PORT Frontend port (default: 3001)" echo " FRONTEND_PORT Frontend port (default: 3001)"
echo " MANAGEMENT_PORT Management portal port (default: 3003)"
;; ;;
esac esac