Fix jwt-token
This commit is contained in:
243
deploy-management.sh
Normal file
243
deploy-management.sh
Normal 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
|
||||||
@@ -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
35
management/Dockerfile
Normal 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
69
management/README.md
Normal 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
97
management/build.sh
Normal 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
13
management/index.html
Normal 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
36
management/nginx.conf
Normal 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
37
management/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
management/postcss.config.js
Normal file
6
management/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
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
|
||||||
20
management/tailwind.config.js
Normal file
20
management/tailwind.config.js
Normal 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
20
management/vite.config.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user