Complete admin panel: Flask backend + React frontend

This commit is contained in:
oguz ozturk 2026-01-11 16:53:46 +03:00
commit 736a1487ab
29 changed files with 2298 additions and 0 deletions

60
.gitignore vendored Normal file
View File

@ -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/

94
README.md Normal file
View File

@ -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

7
backend/app/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
Admin Panel Application Package
"""
from app.main import create_app
__all__ = ['create_app']

31
backend/app/config.py Normal file
View File

@ -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(',')

75
backend/app/main.py Normal file
View File

@ -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)

151
backend/app/models.py Normal file
View File

@ -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,
}

View File

@ -0,0 +1,2 @@
# Routes package

118
backend/app/routes/auth.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

191
backend/app/routes/plans.py Normal file
View File

@ -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

9
backend/requirements.txt Normal file
View File

@ -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

14
frontend/index.html Normal file
View File

@ -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>

26
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

28
frontend/src/App.jsx Normal file
View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;
};

64
frontend/src/index.css Normal file
View File

@ -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;
}
}

11
frontend/src/main.jsx Normal file
View File

@ -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>,
)

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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: [],
}

16
frontend/vite.config.js Normal file
View File

@ -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,
}
}
}
})