Complete admin panel: Flask backend + React frontend
This commit is contained in:
commit
736a1487ab
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Admin Panel - Hosting Platform Management
|
||||||
|
|
||||||
|
Admin panel for managing the hosting platform, customers, subscription plans, and Cloudflare accounts.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
AdminPanel/
|
||||||
|
├── backend/ # Flask API (Port 5001)
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── routes/ # API routes
|
||||||
|
│ │ ├── models.py # Database models
|
||||||
|
│ │ └── main.py # Flask app
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # React + Vite
|
||||||
|
│ └── src/
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Admin Authentication** - Secure admin login system
|
||||||
|
- **Customer Management** - View and manage customers
|
||||||
|
- **Subscription Plans** - Create and manage subscription plans
|
||||||
|
- **Cloudflare Accounts** - Manage company CF accounts
|
||||||
|
- **Audit Logs** - Track all admin actions
|
||||||
|
|
||||||
|
## Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Create database
|
||||||
|
createdb admin_hosting_db
|
||||||
|
|
||||||
|
# Run
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Default admin credentials:
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `admin123`
|
||||||
|
|
||||||
|
## Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Separate PostgreSQL database: `admin_hosting_db`
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
- `admin_users` - Admin accounts
|
||||||
|
- `subscription_plans` - Subscription plans
|
||||||
|
- `cloudflare_accounts` - Company CF accounts
|
||||||
|
- `audit_logs` - Admin action logs
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/login` - Admin login
|
||||||
|
- `GET /api/auth/me` - Get current admin
|
||||||
|
- `POST /api/auth/logout` - Logout
|
||||||
|
|
||||||
|
### Plans
|
||||||
|
- `GET /api/plans` - List all plans
|
||||||
|
- `POST /api/plans` - Create plan
|
||||||
|
- `PUT /api/plans/:id` - Update plan
|
||||||
|
- `DELETE /api/plans/:id` - Delete plan
|
||||||
|
|
||||||
|
### CF Accounts
|
||||||
|
- `GET /api/cf-accounts` - List CF accounts
|
||||||
|
- `POST /api/cf-accounts` - Create CF account
|
||||||
|
- `PUT /api/cf-accounts/:id` - Update CF account
|
||||||
|
- `DELETE /api/cf-accounts/:id` - Delete CF account
|
||||||
|
|
||||||
|
### Customers
|
||||||
|
- `GET /api/customers` - List customers (via customer API)
|
||||||
|
- `GET /api/customers/:id` - Get customer details
|
||||||
|
- `PUT /api/customers/:id/plan` - Update customer plan
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **Domain:** admin.argeict.net
|
||||||
|
- **Backend Port:** 5001
|
||||||
|
- **Database:** admin_hosting_db
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
Admin Panel Application Package
|
||||||
|
"""
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
__all__ = ['create_app']
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""
|
||||||
|
Admin Panel Configuration
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Flask
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'admin-secret-key-change-in-production')
|
||||||
|
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||||
|
'DATABASE_URL',
|
||||||
|
'postgresql://admin_user:admin_pass@localhost/admin_hosting_db'
|
||||||
|
)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'admin-jwt-secret-change-in-production')
|
||||||
|
JWT_EXPIRATION_HOURS = 24
|
||||||
|
|
||||||
|
# Customer API (hosting platform API)
|
||||||
|
CUSTOMER_API_URL = os.getenv('CUSTOMER_API_URL', 'http://localhost:5000')
|
||||||
|
CUSTOMER_API_INTERNAL_KEY = os.getenv('CUSTOMER_API_INTERNAL_KEY', 'internal-api-key')
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS = os.getenv('CORS_ORIGINS', 'http://localhost:5173,https://admin.argeict.net').split(',')
|
||||||
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""
|
||||||
|
Admin Panel - Main Flask Application
|
||||||
|
"""
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
from app.config import Config
|
||||||
|
from app.models import db, AdminUser
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
CORS(app,
|
||||||
|
origins=Config.CORS_ORIGINS,
|
||||||
|
supports_credentials=True,
|
||||||
|
allow_headers=['Content-Type', 'Authorization'],
|
||||||
|
methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'])
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Create default admin user if not exists
|
||||||
|
if not AdminUser.query.filter_by(username='admin').first():
|
||||||
|
admin = AdminUser(
|
||||||
|
username='admin',
|
||||||
|
email='admin@argeict.net',
|
||||||
|
full_name='System Administrator',
|
||||||
|
role='super_admin'
|
||||||
|
)
|
||||||
|
admin.set_password('admin123') # Change this!
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
print("✓ Default admin user created (username: admin, password: admin123)")
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from app.routes.auth import auth_bp
|
||||||
|
from app.routes.plans import plans_bp
|
||||||
|
from app.routes.cf_accounts import cf_accounts_bp
|
||||||
|
from app.routes.customers import customers_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||||
|
app.register_blueprint(plans_bp, url_prefix='/api/plans')
|
||||||
|
app.register_blueprint(cf_accounts_bp, url_prefix='/api/cf-accounts')
|
||||||
|
app.register_blueprint(customers_bp, url_prefix='/api/customers')
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'service': 'admin-panel',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Root endpoint
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return jsonify({
|
||||||
|
'service': 'Admin Panel API',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'status': 'running'
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = create_app()
|
||||||
|
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""
|
||||||
|
Admin Panel Database Models
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
class AdminUser(db.Model):
|
||||||
|
"""Admin users table"""
|
||||||
|
__tablename__ = 'admin_users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
full_name = db.Column(db.String(100))
|
||||||
|
role = db.Column(db.String(20), default='admin') # admin, super_admin
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
"""Hash and set password"""
|
||||||
|
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
"""Verify password"""
|
||||||
|
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'username': self.username,
|
||||||
|
'email': self.email,
|
||||||
|
'full_name': self.full_name,
|
||||||
|
'role': self.role,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPlan(db.Model):
|
||||||
|
"""Subscription plans table"""
|
||||||
|
__tablename__ = 'subscription_plans'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
slug = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
price_monthly = db.Column(db.Numeric(10, 2), default=0)
|
||||||
|
price_yearly = db.Column(db.Numeric(10, 2), default=0)
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
max_domains = db.Column(db.Integer, default=1)
|
||||||
|
max_containers = db.Column(db.Integer, default=1)
|
||||||
|
max_storage_gb = db.Column(db.Integer, default=10)
|
||||||
|
max_bandwidth_gb = db.Column(db.Integer, default=100)
|
||||||
|
|
||||||
|
# Features (JSON)
|
||||||
|
features = db.Column(db.JSON, default=list)
|
||||||
|
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
is_visible = db.Column(db.Boolean, default=True)
|
||||||
|
sort_order = db.Column(db.Integer, default=0)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'slug': self.slug,
|
||||||
|
'description': self.description,
|
||||||
|
'price_monthly': float(self.price_monthly) if self.price_monthly else 0,
|
||||||
|
'price_yearly': float(self.price_yearly) if self.price_yearly else 0,
|
||||||
|
'max_domains': self.max_domains,
|
||||||
|
'max_containers': self.max_containers,
|
||||||
|
'max_storage_gb': self.max_storage_gb,
|
||||||
|
'max_bandwidth_gb': self.max_bandwidth_gb,
|
||||||
|
'features': self.features or [],
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'is_visible': self.is_visible,
|
||||||
|
'sort_order': self.sort_order,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareAccount(db.Model):
|
||||||
|
"""Company Cloudflare accounts"""
|
||||||
|
__tablename__ = 'cloudflare_accounts'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
email = db.Column(db.String(120), nullable=False)
|
||||||
|
api_token = db.Column(db.Text, nullable=False) # Encrypted
|
||||||
|
max_domains = db.Column(db.Integer, default=100)
|
||||||
|
current_domains = db.Column(db.Integer, default=0)
|
||||||
|
notes = db.Column(db.Text)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self, include_token=False):
|
||||||
|
data = {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'email': self.email,
|
||||||
|
'max_domains': self.max_domains,
|
||||||
|
'current_domains': self.current_domains,
|
||||||
|
'notes': self.notes,
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
if include_token:
|
||||||
|
data['api_token'] = self.api_token
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""Audit logs for admin actions"""
|
||||||
|
__tablename__ = 'audit_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
admin_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'))
|
||||||
|
action = db.Column(db.String(100), nullable=False)
|
||||||
|
resource_type = db.Column(db.String(50)) # customer, plan, cf_account
|
||||||
|
resource_id = db.Column(db.Integer)
|
||||||
|
details = db.Column(db.JSON)
|
||||||
|
ip_address = db.Column(db.String(45))
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
admin = db.relationship('AdminUser', backref='audit_logs')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'admin_id': self.admin_id,
|
||||||
|
'admin_username': self.admin.username if self.admin else None,
|
||||||
|
'action': self.action,
|
||||||
|
'resource_type': self.resource_type,
|
||||||
|
'resource_id': self.resource_id,
|
||||||
|
'details': self.details,
|
||||||
|
'ip_address': self.ip_address,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Routes package
|
||||||
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""
|
||||||
|
Admin Authentication Routes
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.models import db, AdminUser, AuditLog
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import jwt
|
||||||
|
from app.config import Config
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
def token_required(f):
|
||||||
|
"""Decorator to require valid JWT token"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
token = request.headers.get('Authorization')
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'Token is missing'}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
if token.startswith('Bearer '):
|
||||||
|
token = token[7:]
|
||||||
|
|
||||||
|
data = jwt.decode(token, Config.JWT_SECRET_KEY, algorithms=['HS256'])
|
||||||
|
current_admin = AdminUser.query.get(data['admin_id'])
|
||||||
|
|
||||||
|
if not current_admin or not current_admin.is_active:
|
||||||
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'Token has expired'}), 401
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
return f(current_admin, *args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
"""Admin login"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
username = data.get('username')
|
||||||
|
password = data.get('password')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({'error': 'Username and password required'}), 400
|
||||||
|
|
||||||
|
admin = AdminUser.query.filter_by(username=username).first()
|
||||||
|
|
||||||
|
if not admin or not admin.check_password(password):
|
||||||
|
return jsonify({'error': 'Invalid credentials'}), 401
|
||||||
|
|
||||||
|
if not admin.is_active:
|
||||||
|
return jsonify({'error': 'Account is disabled'}), 403
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
admin.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Create JWT token
|
||||||
|
token = jwt.encode({
|
||||||
|
'admin_id': admin.id,
|
||||||
|
'username': admin.username,
|
||||||
|
'role': admin.role,
|
||||||
|
'exp': datetime.utcnow() + timedelta(hours=Config.JWT_EXPIRATION_HOURS),
|
||||||
|
'iat': datetime.utcnow()
|
||||||
|
}, Config.JWT_SECRET_KEY, algorithm='HS256')
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=admin.id,
|
||||||
|
action='login',
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Login successful',
|
||||||
|
'token': token,
|
||||||
|
'admin': admin.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@auth_bp.route('/me', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_current_admin(current_admin):
|
||||||
|
"""Get current admin info"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'admin': current_admin.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@auth_bp.route('/logout', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def logout(current_admin):
|
||||||
|
"""Admin logout"""
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='logout',
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Logged out successfully'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""
|
||||||
|
Cloudflare Accounts Management Routes
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.models import db, CloudflareAccount, AuditLog
|
||||||
|
from app.routes.auth import token_required
|
||||||
|
|
||||||
|
cf_accounts_bp = Blueprint('cf_accounts', __name__)
|
||||||
|
|
||||||
|
@cf_accounts_bp.route('', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_cf_accounts(current_admin):
|
||||||
|
"""Get all CF accounts"""
|
||||||
|
try:
|
||||||
|
accounts = CloudflareAccount.query.order_by(CloudflareAccount.created_at.desc()).all()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'accounts': [acc.to_dict() for acc in accounts]
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@cf_accounts_bp.route('/<int:account_id>', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_cf_account(current_admin, account_id):
|
||||||
|
"""Get single CF account"""
|
||||||
|
try:
|
||||||
|
account = CloudflareAccount.query.get(account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({'error': 'Account not found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'account': account.to_dict(include_token=True)
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@cf_accounts_bp.route('', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def create_cf_account(current_admin):
|
||||||
|
"""Create new CF account"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
required = ['name', 'email', 'api_token']
|
||||||
|
for field in required:
|
||||||
|
if not data.get(field):
|
||||||
|
return jsonify({'error': f'{field} is required'}), 400
|
||||||
|
|
||||||
|
account = CloudflareAccount(
|
||||||
|
name=data['name'],
|
||||||
|
email=data['email'],
|
||||||
|
api_token=data['api_token'], # TODO: Encrypt this
|
||||||
|
max_domains=data.get('max_domains', 100),
|
||||||
|
notes=data.get('notes'),
|
||||||
|
is_active=data.get('is_active', True)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(account)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='create_cf_account',
|
||||||
|
resource_type='cf_account',
|
||||||
|
resource_id=account.id,
|
||||||
|
details={'account_name': account.name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'CF account created successfully',
|
||||||
|
'account': account.to_dict()
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@cf_accounts_bp.route('/<int:account_id>', methods=['PUT'])
|
||||||
|
@token_required
|
||||||
|
def update_cf_account(current_admin, account_id):
|
||||||
|
"""Update CF account"""
|
||||||
|
try:
|
||||||
|
account = CloudflareAccount.query.get(account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({'error': 'Account not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
account.name = data['name']
|
||||||
|
if 'email' in data:
|
||||||
|
account.email = data['email']
|
||||||
|
if 'api_token' in data and data['api_token']:
|
||||||
|
account.api_token = data['api_token'] # TODO: Encrypt
|
||||||
|
if 'max_domains' in data:
|
||||||
|
account.max_domains = data['max_domains']
|
||||||
|
if 'notes' in data:
|
||||||
|
account.notes = data['notes']
|
||||||
|
if 'is_active' in data:
|
||||||
|
account.is_active = data['is_active']
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='update_cf_account',
|
||||||
|
resource_type='cf_account',
|
||||||
|
resource_id=account.id,
|
||||||
|
details={'account_name': account.name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'CF account updated successfully',
|
||||||
|
'account': account.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@cf_accounts_bp.route('/<int:account_id>', methods=['DELETE'])
|
||||||
|
@token_required
|
||||||
|
def delete_cf_account(current_admin, account_id):
|
||||||
|
"""Delete CF account"""
|
||||||
|
try:
|
||||||
|
account = CloudflareAccount.query.get(account_id)
|
||||||
|
if not account:
|
||||||
|
return jsonify({'error': 'Account not found'}), 404
|
||||||
|
|
||||||
|
if account.current_domains > 0:
|
||||||
|
return jsonify({'error': 'Cannot delete account with active domains'}), 400
|
||||||
|
|
||||||
|
account_name = account.name
|
||||||
|
db.session.delete(account)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='delete_cf_account',
|
||||||
|
resource_type='cf_account',
|
||||||
|
resource_id=account_id,
|
||||||
|
details={'account_name': account_name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'CF account deleted successfully'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""
|
||||||
|
Customer Management Routes (via Customer API)
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.routes.auth import token_required
|
||||||
|
from app.config import Config
|
||||||
|
import requests
|
||||||
|
|
||||||
|
customers_bp = Blueprint('customers', __name__)
|
||||||
|
|
||||||
|
def call_customer_api(endpoint, method='GET', data=None):
|
||||||
|
"""Helper to call customer platform API"""
|
||||||
|
url = f"{Config.CUSTOMER_API_URL}{endpoint}"
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Internal-Key': Config.CUSTOMER_API_INTERNAL_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == 'GET':
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
elif method == 'POST':
|
||||||
|
response = requests.post(url, headers=headers, json=data, timeout=10)
|
||||||
|
elif method == 'PUT':
|
||||||
|
response = requests.put(url, headers=headers, json=data, timeout=10)
|
||||||
|
elif method == 'DELETE':
|
||||||
|
response = requests.delete(url, headers=headers, timeout=10)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response.json() if response.status_code < 500 else None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error calling customer API: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@customers_bp.route('', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_customers(current_admin):
|
||||||
|
"""Get all customers from customer platform"""
|
||||||
|
try:
|
||||||
|
# TODO: Implement customer API endpoint
|
||||||
|
result = call_customer_api('/api/admin/customers')
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return jsonify(result), 200
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'customers': [],
|
||||||
|
'message': 'Customer API not yet implemented'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_customer(current_admin, customer_id):
|
||||||
|
"""Get single customer"""
|
||||||
|
try:
|
||||||
|
result = call_customer_api(f'/api/admin/customers/{customer_id}')
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return jsonify(result), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Customer not found'}), 404
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@customers_bp.route('/<int:customer_id>/plan', methods=['PUT'])
|
||||||
|
@token_required
|
||||||
|
def update_customer_plan(current_admin, customer_id):
|
||||||
|
"""Update customer's subscription plan"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
result = call_customer_api(
|
||||||
|
f'/api/admin/customers/{customer_id}/plan',
|
||||||
|
method='PUT',
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return jsonify(result), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Failed to update plan'}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@customers_bp.route('/stats', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_customer_stats(current_admin):
|
||||||
|
"""Get customer statistics"""
|
||||||
|
try:
|
||||||
|
result = call_customer_api('/api/admin/stats')
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return jsonify(result), 200
|
||||||
|
else:
|
||||||
|
# Return mock data for now
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'stats': {
|
||||||
|
'total_customers': 0,
|
||||||
|
'active_customers': 0,
|
||||||
|
'total_domains': 0,
|
||||||
|
'total_containers': 0
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""
|
||||||
|
Subscription Plans Management Routes
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.models import db, SubscriptionPlan, AuditLog
|
||||||
|
from app.routes.auth import token_required
|
||||||
|
|
||||||
|
plans_bp = Blueprint('plans', __name__)
|
||||||
|
|
||||||
|
@plans_bp.route('', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_plans(current_admin):
|
||||||
|
"""Get all subscription plans"""
|
||||||
|
try:
|
||||||
|
plans = SubscriptionPlan.query.order_by(SubscriptionPlan.sort_order).all()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'plans': [plan.to_dict() for plan in plans]
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@plans_bp.route('/<int:plan_id>', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_plan(current_admin, plan_id):
|
||||||
|
"""Get single plan"""
|
||||||
|
try:
|
||||||
|
plan = SubscriptionPlan.query.get(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return jsonify({'error': 'Plan not found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'plan': plan.to_dict()
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@plans_bp.route('', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def create_plan(current_admin):
|
||||||
|
"""Create new subscription plan"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required = ['name', 'slug']
|
||||||
|
for field in required:
|
||||||
|
if not data.get(field):
|
||||||
|
return jsonify({'error': f'{field} is required'}), 400
|
||||||
|
|
||||||
|
# Check if slug already exists
|
||||||
|
if SubscriptionPlan.query.filter_by(slug=data['slug']).first():
|
||||||
|
return jsonify({'error': 'Plan with this slug already exists'}), 400
|
||||||
|
|
||||||
|
plan = SubscriptionPlan(
|
||||||
|
name=data['name'],
|
||||||
|
slug=data['slug'],
|
||||||
|
description=data.get('description'),
|
||||||
|
price_monthly=data.get('price_monthly', 0),
|
||||||
|
price_yearly=data.get('price_yearly', 0),
|
||||||
|
max_domains=data.get('max_domains', 1),
|
||||||
|
max_containers=data.get('max_containers', 1),
|
||||||
|
max_storage_gb=data.get('max_storage_gb', 10),
|
||||||
|
max_bandwidth_gb=data.get('max_bandwidth_gb', 100),
|
||||||
|
features=data.get('features', []),
|
||||||
|
is_active=data.get('is_active', True),
|
||||||
|
is_visible=data.get('is_visible', True),
|
||||||
|
sort_order=data.get('sort_order', 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(plan)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='create_plan',
|
||||||
|
resource_type='plan',
|
||||||
|
resource_id=plan.id,
|
||||||
|
details={'plan_name': plan.name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Plan created successfully',
|
||||||
|
'plan': plan.to_dict()
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@plans_bp.route('/<int:plan_id>', methods=['PUT'])
|
||||||
|
@token_required
|
||||||
|
def update_plan(current_admin, plan_id):
|
||||||
|
"""Update subscription plan"""
|
||||||
|
try:
|
||||||
|
plan = SubscriptionPlan.query.get(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return jsonify({'error': 'Plan not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if 'name' in data:
|
||||||
|
plan.name = data['name']
|
||||||
|
if 'description' in data:
|
||||||
|
plan.description = data['description']
|
||||||
|
if 'price_monthly' in data:
|
||||||
|
plan.price_monthly = data['price_monthly']
|
||||||
|
if 'price_yearly' in data:
|
||||||
|
plan.price_yearly = data['price_yearly']
|
||||||
|
if 'max_domains' in data:
|
||||||
|
plan.max_domains = data['max_domains']
|
||||||
|
if 'max_containers' in data:
|
||||||
|
plan.max_containers = data['max_containers']
|
||||||
|
if 'max_storage_gb' in data:
|
||||||
|
plan.max_storage_gb = data['max_storage_gb']
|
||||||
|
if 'max_bandwidth_gb' in data:
|
||||||
|
plan.max_bandwidth_gb = data['max_bandwidth_gb']
|
||||||
|
if 'features' in data:
|
||||||
|
plan.features = data['features']
|
||||||
|
if 'is_active' in data:
|
||||||
|
plan.is_active = data['is_active']
|
||||||
|
if 'is_visible' in data:
|
||||||
|
plan.is_visible = data['is_visible']
|
||||||
|
if 'sort_order' in data:
|
||||||
|
plan.sort_order = data['sort_order']
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='update_plan',
|
||||||
|
resource_type='plan',
|
||||||
|
resource_id=plan.id,
|
||||||
|
details={'plan_name': plan.name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Plan updated successfully',
|
||||||
|
'plan': plan.to_dict()
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@plans_bp.route('/<int:plan_id>', methods=['DELETE'])
|
||||||
|
@token_required
|
||||||
|
def delete_plan(current_admin, plan_id):
|
||||||
|
"""Delete subscription plan"""
|
||||||
|
try:
|
||||||
|
plan = SubscriptionPlan.query.get(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return jsonify({'error': 'Plan not found'}), 404
|
||||||
|
|
||||||
|
plan_name = plan.name
|
||||||
|
db.session.delete(plan)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
log = AuditLog(
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
action='delete_plan',
|
||||||
|
resource_type='plan',
|
||||||
|
resource_id=plan_id,
|
||||||
|
details={'plan_name': plan_name},
|
||||||
|
ip_address=request.remote_addr
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Plan deleted successfully'
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
Flask==3.0.0
|
||||||
|
Flask-CORS==4.0.0
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
PyJWT==2.8.0
|
||||||
|
bcrypt==4.1.2
|
||||||
|
requests==2.31.0
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin Panel - Hosting Platform</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "admin-panel-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"axios": "^1.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Plans from './pages/Plans'
|
||||||
|
import CFAccounts from './pages/CFAccounts'
|
||||||
|
import Customers from './pages/Customers'
|
||||||
|
import PrivateRoute from './components/PrivateRoute'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
|
||||||
|
<Route path="/plans" element={<PrivateRoute><Plans /></PrivateRoute>} />
|
||||||
|
<Route path="/cf-accounts" element={<PrivateRoute><CFAccounts /></PrivateRoute>} />
|
||||||
|
<Route path="/customers" element={<PrivateRoute><Customers /></PrivateRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const Layout = ({ children }) => {
|
||||||
|
const { admin, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ path: '/customers', label: 'Customers', icon: '👥' },
|
||||||
|
{ path: '/plans', label: 'Plans', icon: '📦' },
|
||||||
|
{ path: '/cf-accounts', label: 'CF Accounts', icon: '☁️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-white border-r border-gray-200">
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-600">Admin Panel</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Hosting Platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="px-4 space-y-1">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{item.icon}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 w-64 p-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-primary-600 font-semibold">
|
||||||
|
{admin?.username?.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{admin?.full_name || admin?.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{admin?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<div className="p-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const PrivateRoute = ({ children }) => {
|
||||||
|
const { admin, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return admin ? children : <Navigate to="/login" replace />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivateRoute;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [admin, setAdmin] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
const savedAdmin = localStorage.getItem('admin_user');
|
||||||
|
|
||||||
|
if (token && savedAdmin) {
|
||||||
|
try {
|
||||||
|
setAdmin(JSON.parse(savedAdmin));
|
||||||
|
// Verify token is still valid
|
||||||
|
await api.get('/api/auth/me');
|
||||||
|
} catch (error) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/auth/login', { username, password });
|
||||||
|
const { token, admin: adminData } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('admin_token', token);
|
||||||
|
localStorage.setItem('admin_user', JSON.stringify(adminData));
|
||||||
|
setAdmin(adminData);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error || 'Login failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
setAdmin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ admin, login, logout, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply px-4 py-2 bg-white text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
@apply w-full border-collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
@apply bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3 border-b border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900 border-b border-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
@apply bg-green-100 text-green-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply bg-yellow-100 text-yellow-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
@apply bg-red-100 text-red-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply bg-blue-100 text-blue-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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>,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const CFAccounts = () => {
|
||||||
|
const [accounts, setAccounts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingAccount, setEditingAccount] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
api_token: '',
|
||||||
|
max_domains: 100,
|
||||||
|
notes: '',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/cf-accounts');
|
||||||
|
setAccounts(response.data.accounts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch CF accounts:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingAccount) {
|
||||||
|
await api.put(`/api/cf-accounts/${editingAccount.id}`, formData);
|
||||||
|
} else {
|
||||||
|
await api.post('/api/cf-accounts', formData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
resetForm();
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.response?.data?.error || 'Failed to save CF account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this CF account?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/cf-accounts/${id}`);
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.response?.data?.error || 'Failed to delete CF account');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = (account = null) => {
|
||||||
|
if (account) {
|
||||||
|
setEditingAccount(account);
|
||||||
|
setFormData({
|
||||||
|
name: account.name,
|
||||||
|
email: account.email,
|
||||||
|
api_token: '', // Don't pre-fill token for security
|
||||||
|
max_domains: account.max_domains,
|
||||||
|
notes: account.notes || '',
|
||||||
|
is_active: account.is_active,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingAccount(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
api_token: '',
|
||||||
|
max_domains: 100,
|
||||||
|
notes: '',
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Cloudflare Accounts</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Manage company Cloudflare accounts</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => openModal()} className="btn-primary">
|
||||||
|
+ New CF Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Domains</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<tr key={account.id}>
|
||||||
|
<td className="font-medium">{account.name}</td>
|
||||||
|
<td className="text-gray-600">{account.email}</td>
|
||||||
|
<td>
|
||||||
|
{account.current_domains} / {account.max_domains}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${account.is_active ? 'badge-success' : 'badge-danger'}`}>
|
||||||
|
{account.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-gray-600 text-sm">
|
||||||
|
{new Date(account.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => openModal(account)} className="text-primary-600 hover:text-primary-700 mr-3">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(account.id)} className="text-red-600 hover:text-red-700">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-lg">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">
|
||||||
|
{editingAccount ? 'Edit CF Account' : 'Create New CF Account'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
API Token {editingAccount ? '(leave empty to keep current)' : '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.api_token}
|
||||||
|
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
required={!editingAccount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Domains</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.max_domains}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_domains: parseInt(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="btn-primary flex-1">
|
||||||
|
{editingAccount ? 'Update Account' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CFAccounts;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const Customers = () => {
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/customers');
|
||||||
|
setCustomers(response.data.customers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch customers:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Customers</h1>
|
||||||
|
<p className="text-gray-600 mt-1">View and manage customer accounts</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">👥</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Customers Yet</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Customer data will appear here once the customer platform API is connected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Domains</th>
|
||||||
|
<th>Containers</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<tr key={customer.id}>
|
||||||
|
<td className="font-medium">{customer.name}</td>
|
||||||
|
<td className="text-gray-600">{customer.email}</td>
|
||||||
|
<td>
|
||||||
|
<span className="badge badge-info">{customer.plan}</span>
|
||||||
|
</td>
|
||||||
|
<td>{customer.domains_count || 0}</td>
|
||||||
|
<td>{customer.containers_count || 0}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${customer.is_active ? 'badge-success' : 'badge-danger'}`}>
|
||||||
|
{customer.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-gray-600 text-sm">
|
||||||
|
{new Date(customer.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="text-primary-600 hover:text-primary-700">
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Customers;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
total_customers: 0,
|
||||||
|
active_customers: 0,
|
||||||
|
total_domains: 0,
|
||||||
|
total_containers: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/customers/stats');
|
||||||
|
setStats(response.data.stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch stats:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: 'Total Customers',
|
||||||
|
value: stats.total_customers,
|
||||||
|
icon: '👥',
|
||||||
|
color: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active Customers',
|
||||||
|
value: stats.active_customers,
|
||||||
|
icon: '✅',
|
||||||
|
color: 'bg-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Domains',
|
||||||
|
value: stats.total_domains,
|
||||||
|
icon: '🌐',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Containers',
|
||||||
|
value: stats.total_containers,
|
||||||
|
icon: '📦',
|
||||||
|
color: 'bg-orange-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Overview of your hosting platform</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading stats...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{statCards.map((stat, index) => (
|
||||||
|
<div key={index} className="card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">{stat.title}</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center text-2xl`}
|
||||||
|
>
|
||||||
|
{stat.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
|
||||||
|
<p className="text-gray-600 text-sm">No recent activity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<a href="/plans" className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<p className="font-medium text-gray-900">Manage Plans</p>
|
||||||
|
<p className="text-sm text-gray-600">Create and edit subscription plans</p>
|
||||||
|
</a>
|
||||||
|
<a href="/cf-accounts" className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<p className="font-medium text-gray-900">CF Accounts</p>
|
||||||
|
<p className="text-sm text-gray-600">Manage Cloudflare accounts</p>
|
||||||
|
</a>
|
||||||
|
<a href="/customers" className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<p className="font-medium text-gray-900">View Customers</p>
|
||||||
|
<p className="text-sm text-gray-600">See all registered customers</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login, admin } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (admin) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [admin, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await login(username, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Admin Panel</h1>
|
||||||
|
<p className="text-gray-600">Hosting Platform Management</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-800 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
<strong>Default credentials:</strong><br />
|
||||||
|
Username: admin<br />
|
||||||
|
Password: admin123
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const Plans = () => {
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingPlan, setEditingPlan] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
price_monthly: 0,
|
||||||
|
price_yearly: 0,
|
||||||
|
max_domains: 1,
|
||||||
|
max_containers: 1,
|
||||||
|
max_storage_gb: 10,
|
||||||
|
max_bandwidth_gb: 100,
|
||||||
|
features: [],
|
||||||
|
is_active: true,
|
||||||
|
is_visible: true,
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/plans');
|
||||||
|
setPlans(response.data.plans);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plans:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingPlan) {
|
||||||
|
await api.put(`/api/plans/${editingPlan.id}`, formData);
|
||||||
|
} else {
|
||||||
|
await api.post('/api/plans', formData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
resetForm();
|
||||||
|
fetchPlans();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.response?.data?.error || 'Failed to save plan');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this plan?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/plans/${id}`);
|
||||||
|
fetchPlans();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.response?.data?.error || 'Failed to delete plan');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = (plan = null) => {
|
||||||
|
if (plan) {
|
||||||
|
setEditingPlan(plan);
|
||||||
|
setFormData(plan);
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingPlan(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
price_monthly: 0,
|
||||||
|
price_yearly: 0,
|
||||||
|
max_domains: 1,
|
||||||
|
max_containers: 1,
|
||||||
|
max_storage_gb: 10,
|
||||||
|
max_bandwidth_gb: 100,
|
||||||
|
features: [],
|
||||||
|
is_active: true,
|
||||||
|
is_visible: true,
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Subscription Plans</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Manage subscription plans for customers</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => openModal()} className="btn-primary">
|
||||||
|
+ New Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Price (Monthly)</th>
|
||||||
|
<th>Price (Yearly)</th>
|
||||||
|
<th>Limits</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<tr key={plan.id}>
|
||||||
|
<td className="font-medium">{plan.name}</td>
|
||||||
|
<td className="text-gray-600">{plan.slug}</td>
|
||||||
|
<td>${plan.price_monthly}</td>
|
||||||
|
<td>${plan.price_yearly}</td>
|
||||||
|
<td className="text-sm text-gray-600">
|
||||||
|
{plan.max_domains}D / {plan.max_containers}C / {plan.max_storage_gb}GB
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${plan.is_active ? 'badge-success' : 'badge-danger'}`}>
|
||||||
|
{plan.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => openModal(plan)} className="text-primary-600 hover:text-primary-700 mr-3">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(plan.id)} className="text-red-600 hover:text-red-700">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">
|
||||||
|
{editingPlan ? 'Edit Plan' : 'Create New Plan'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Slug *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.slug}
|
||||||
|
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="input-field"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Price Monthly ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.price_monthly}
|
||||||
|
onChange={(e) => setFormData({ ...formData, price_monthly: parseFloat(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Price Yearly ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.price_yearly}
|
||||||
|
onChange={(e) => setFormData({ ...formData, price_yearly: parseFloat(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Domains</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.max_domains}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_domains: parseInt(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Containers</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.max_containers}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_containers: parseInt(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Storage (GB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.max_storage_gb}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_storage_gb: parseInt(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Max Bandwidth (GB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.max_bandwidth_gb}
|
||||||
|
onChange={(e) => setFormData({ ...formData, max_bandwidth_gb: parseInt(e.target.value) })}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Active</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_visible}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_visible: e.target.checked })}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Visible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button type="submit" className="btn-primary flex-1">
|
||||||
|
{editingPlan ? 'Update Plan' : 'Create Plan'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Plans;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add token to requests
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle response errors
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
localStorage.removeItem('admin_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5001',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue