commit 736a1487ab4fb73066419ef4c6f42e1f506a5ecd Author: oguz ozturk Date: Sun Jan 11 16:53:46 2026 +0300 Complete admin panel: Flask backend + React frontend diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43f5c18 --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..de12df9 --- /dev/null +++ b/README.md @@ -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 + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..49c6014 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,7 @@ +""" +Admin Panel Application Package +""" +from app.main import create_app + +__all__ = ['create_app'] + diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..bead867 --- /dev/null +++ b/backend/app/config.py @@ -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(',') + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f0b4e9b --- /dev/null +++ b/backend/app/main.py @@ -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) + diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..cb61991 --- /dev/null +++ b/backend/app/models.py @@ -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, + } + diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..fb42c31 --- /dev/null +++ b/backend/app/routes/__init__.py @@ -0,0 +1,2 @@ +# Routes package + diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..05e33be --- /dev/null +++ b/backend/app/routes/auth.py @@ -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 + diff --git a/backend/app/routes/cf_accounts.py b/backend/app/routes/cf_accounts.py new file mode 100644 index 0000000..6792004 --- /dev/null +++ b/backend/app/routes/cf_accounts.py @@ -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('/', 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('/', 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('/', 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 + diff --git a/backend/app/routes/customers.py b/backend/app/routes/customers.py new file mode 100644 index 0000000..29ca51e --- /dev/null +++ b/backend/app/routes/customers.py @@ -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('/', 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('//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 + diff --git a/backend/app/routes/plans.py b/backend/app/routes/plans.py new file mode 100644 index 0000000..9bf6696 --- /dev/null +++ b/backend/app/routes/plans.py @@ -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('/', 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('/', 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('/', 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 + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..59c0414 --- /dev/null +++ b/backend/requirements.txt @@ -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 + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..86efced --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Admin Panel - Hosting Platform + + +
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cb80270 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} + diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..b4a6220 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..e39c9c0 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App + diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..97458d9 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+
{children}
+
+
+ ); +}; + +export default Layout; + diff --git a/frontend/src/components/PrivateRoute.jsx b/frontend/src/components/PrivateRoute.jsx new file mode 100644 index 0000000..a2d6832 --- /dev/null +++ b/frontend/src/components/PrivateRoute.jsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + return admin ? children : ; +}; + +export default PrivateRoute; + diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..b975bb7 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..0303fd1 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} + diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..299bc52 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) + diff --git a/frontend/src/pages/CFAccounts.jsx b/frontend/src/pages/CFAccounts.jsx new file mode 100644 index 0000000..56964b8 --- /dev/null +++ b/frontend/src/pages/CFAccounts.jsx @@ -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 ( + +
+
+

Cloudflare Accounts

+

Manage company Cloudflare accounts

+
+ +
+ + {loading ? ( +
+
+
+ ) : ( +
+ + + + + + + + + + + + + {accounts.map((account) => ( + + + + + + + + + ))} + +
NameEmailDomainsStatusCreatedActions
{account.name}{account.email} + {account.current_domains} / {account.max_domains} + + + {account.is_active ? 'Active' : 'Inactive'} + + + {new Date(account.created_at).toLocaleDateString()} + + + +
+
+ )} + + {/* Modal */} + {showModal && ( +
+
+

+ {editingAccount ? 'Edit CF Account' : 'Create New CF Account'} +

+
+
+ + setFormData({ ...formData, name: e.target.value })} + className="input-field" + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + className="input-field" + required + /> +
+ +
+ + setFormData({ ...formData, api_token: e.target.value })} + className="input-field" + required={!editingAccount} + /> +
+ +
+ + setFormData({ ...formData, max_domains: parseInt(e.target.value) })} + className="input-field" + /> +
+ +
+ +