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