Jun 27 MT-4
This commit is contained in:
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
control/panel/__init__.py
|
||||||
|
--------------------------
|
||||||
|
Superadmin control panel — MT-4.
|
||||||
|
|
||||||
|
Serves as a STANDALONE Flask application at admin.jqc.app (separate WSGI
|
||||||
|
process). Shares only the `control` package (models, provision, tenant_migrate)
|
||||||
|
with the main app. Has NO import dependency on `app/`.
|
||||||
|
|
||||||
|
Entry point: wsgi_panel.py
|
||||||
|
gunicorn wsgi_panel:panel_app
|
||||||
|
|
||||||
|
Env vars required (same /etc/jqc/control.env sourced by the main app):
|
||||||
|
CONTROL_DATABASE_URL
|
||||||
|
CONTROL_FERNET_KEY
|
||||||
|
PANEL_SECRET_KEY — separate from the main app SECRET_KEY
|
||||||
|
PANEL_IMPERSONATE_KEY — HMAC key for impersonation tokens (32+ random bytes)
|
||||||
|
TENANT_BASE_DOMAIN — e.g. jqc.app
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from .auth import bp as auth_bp
|
||||||
|
from .tenants import bp as tenants_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_panel_app():
|
||||||
|
app = Flask(__name__, template_folder='templates')
|
||||||
|
|
||||||
|
secret = os.environ.get('PANEL_SECRET_KEY')
|
||||||
|
if not secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
'PANEL_SECRET_KEY is not set. '
|
||||||
|
'Generate one: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||||
|
)
|
||||||
|
app.secret_key = secret
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
|
log_level = logging.INFO
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
|
)
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setLevel(log_level)
|
||||||
|
sh.setFormatter(formatter)
|
||||||
|
|
||||||
|
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.abspath(__file__)))), 'logs')
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
fh = RotatingFileHandler(
|
||||||
|
os.path.join(log_dir, 'jqc_panel.log'),
|
||||||
|
maxBytes=5 * 1024 * 1024, backupCount=5)
|
||||||
|
fh.setLevel(log_level)
|
||||||
|
fh.setFormatter(formatter)
|
||||||
|
|
||||||
|
app.logger.setLevel(log_level)
|
||||||
|
for h in (sh, fh):
|
||||||
|
app.logger.addHandler(h)
|
||||||
|
|
||||||
|
# ── Blueprints ────────────────────────────────────────────────────────
|
||||||
|
app.register_blueprint(auth_bp) # /login, /logout
|
||||||
|
app.register_blueprint(tenants_bp) # /tenants/…
|
||||||
|
|
||||||
|
# ── Root redirect ─────────────────────────────────────────────────────
|
||||||
|
from flask import redirect, url_for
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
|
||||||
|
# ── Security headers ──────────────────────────────────────────────────
|
||||||
|
@app.after_request
|
||||||
|
def security_headers(response):
|
||||||
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
||||||
|
response.headers.setdefault('X-Frame-Options', 'DENY')
|
||||||
|
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
response.headers.setdefault(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"frame-ancestors 'none';"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
control/panel/auth.py
|
||||||
|
---------------------
|
||||||
|
Superadmin authentication for the control panel (MT-4).
|
||||||
|
|
||||||
|
Uses Flask session directly (no Flask-Login) — the panel is a standalone
|
||||||
|
app with no dependency on the main app's login_manager.
|
||||||
|
|
||||||
|
Session key: 'sa_id' (superadmin.id when logged in)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for,
|
||||||
|
request, session, flash,
|
||||||
|
)
|
||||||
|
|
||||||
|
from control.base import control_session
|
||||||
|
from control.models import Superadmin, TenantAudit
|
||||||
|
from control.time_utils import now_eastern
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_audit(action, superadmin_id=None, tenant_id=None, details=None):
|
||||||
|
"""Write a TenantAudit row for superadmin actions."""
|
||||||
|
try:
|
||||||
|
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||||
|
or request.remote_addr)
|
||||||
|
with control_session() as s:
|
||||||
|
s.add(TenantAudit(
|
||||||
|
superadmin_id=superadmin_id,
|
||||||
|
action=action[:50],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=(ip or '')[:45],
|
||||||
|
created_at=now_eastern(),
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('TenantAudit write failed: %s', exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if session.get('sa_id'):
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = (request.form.get('username') or '').strip()
|
||||||
|
password = request.form.get('password') or ''
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.query(Superadmin).filter_by(username=username, active=True).first()
|
||||||
|
if sa and sa.check_password(password):
|
||||||
|
session.permanent = True
|
||||||
|
session['sa_id'] = sa.id
|
||||||
|
session['sa_username'] = sa.username
|
||||||
|
logger.info('PANEL | login | superadmin=%s', username)
|
||||||
|
_log_audit('LOGIN', superadmin_id=sa.id,
|
||||||
|
details=f'superadmin={username}')
|
||||||
|
flash(f'Welcome, {sa.username}!', 'success')
|
||||||
|
next_url = request.args.get('next') or url_for('tenants.list_tenants')
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
logger.warning('PANEL | login_fail | username=%s', username)
|
||||||
|
error = 'Invalid credentials.'
|
||||||
|
|
||||||
|
return render_template('panel/login.html', error=error)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Logout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
def logout():
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
sa_name = session.get('sa_username', '?')
|
||||||
|
session.clear()
|
||||||
|
logger.info('PANEL | logout | superadmin=%s', sa_name)
|
||||||
|
if sa_id:
|
||||||
|
_log_audit('LOGOUT', superadmin_id=sa_id, details=f'superadmin={sa_name}')
|
||||||
|
flash('Logged out.', 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
control/panel/decorators.py
|
||||||
|
----------------------------
|
||||||
|
Access-control decorator for the superadmin control panel (MT-4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from flask import session, redirect, url_for, request, flash
|
||||||
|
|
||||||
|
from control.base import control_session
|
||||||
|
from control.models import Superadmin
|
||||||
|
|
||||||
|
|
||||||
|
def superadmin_required(f):
|
||||||
|
"""Redirect to /login if the session has no valid superadmin."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
if not sa_id:
|
||||||
|
flash('Please log in.', 'warning')
|
||||||
|
return redirect(url_for('auth.login', next=request.path))
|
||||||
|
# Re-validate: account might have been deactivated since login.
|
||||||
|
with control_session() as s:
|
||||||
|
sa = s.get(Superadmin, sa_id)
|
||||||
|
if sa is None or not sa.active:
|
||||||
|
session.clear()
|
||||||
|
flash('Your superadmin account is no longer active.', 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
control/panel/impersonate.py
|
||||||
|
-----------------------------
|
||||||
|
Impersonation flow (MT-4).
|
||||||
|
|
||||||
|
How it works
|
||||||
|
------------
|
||||||
|
1. Superadmin clicks "Impersonate" on the panel → panel generates a short-lived
|
||||||
|
signed token containing {tenant_id, expires_at, issued_by}.
|
||||||
|
2. Panel redirects the superadmin's browser to
|
||||||
|
https://<tenant-subdomain>/auth/impersonate?token=<token>
|
||||||
|
(the main JQC app, not the panel).
|
||||||
|
3. The main app's impersonation route (app/routes/auth.py) validates the token,
|
||||||
|
sets a session key 'impersonating_tenant_id', and shows a banner.
|
||||||
|
The tenancy middleware reads this key to bind the session to that tenant's DB
|
||||||
|
even when multi-tenancy is enabled and the Host would resolve differently.
|
||||||
|
4. "End impersonation" clears the key and redirects back to the panel.
|
||||||
|
|
||||||
|
Token format: HMAC-SHA256 signed JSON, base64url-encoded.
|
||||||
|
|
||||||
|
Environment
|
||||||
|
-----------
|
||||||
|
PANEL_IMPERSONATE_KEY — 32+ random hex bytes (separate from PANEL_SECRET_KEY).
|
||||||
|
Generate: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
Must be identical in /etc/jqc/control.env AND the main
|
||||||
|
app's environment so both sides can verify tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||||
|
|
||||||
|
_TTL_SECONDS = 60 # token is single-use; 60 s is more than enough for a redirect
|
||||||
|
|
||||||
|
|
||||||
|
def _key() -> bytes:
|
||||||
|
raw = os.environ.get('PANEL_IMPERSONATE_KEY', '')
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError(
|
||||||
|
'PANEL_IMPERSONATE_KEY is not set. '
|
||||||
|
'Generate: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||||
|
)
|
||||||
|
return raw.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(s: str) -> bytes:
|
||||||
|
pad = 4 - len(s) % 4
|
||||||
|
return urlsafe_b64decode(s + '=' * (pad % 4))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token(tenant_id: int, superadmin_id: int) -> str:
|
||||||
|
"""Return a URL-safe signed token good for _TTL_SECONDS."""
|
||||||
|
payload = json.dumps({
|
||||||
|
'tid': tenant_id,
|
||||||
|
'said': superadmin_id,
|
||||||
|
'exp': int(time.time()) + _TTL_SECONDS,
|
||||||
|
}, separators=(',', ':')).encode()
|
||||||
|
sig = hmac.new(_key(), payload, hashlib.sha256).digest()
|
||||||
|
return f'{_b64(payload)}.{_b64(sig)}'
|
||||||
|
|
||||||
|
|
||||||
|
def validate_token(token: str) -> dict:
|
||||||
|
"""Validate and decode a token. Returns payload dict or raises ValueError."""
|
||||||
|
try:
|
||||||
|
raw_payload, raw_sig = token.rsplit('.', 1)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('Malformed token')
|
||||||
|
|
||||||
|
payload_bytes = _unb64(raw_payload)
|
||||||
|
expected_sig = hmac.new(_key(), payload_bytes, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(expected_sig, _unb64(raw_sig)):
|
||||||
|
raise ValueError('Invalid token signature')
|
||||||
|
|
||||||
|
payload = json.loads(payload_bytes)
|
||||||
|
if time.time() > payload.get('exp', 0):
|
||||||
|
raise ValueError('Token expired')
|
||||||
|
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}JQC Control Panel{% endblock %}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background: #f1f5f9; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
.panel-sidebar {
|
||||||
|
width: 220px; min-height: 100vh; background: #0f172a; color: #cbd5e1;
|
||||||
|
position: fixed; top: 0; left: 0; padding: 0; z-index: 100;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.panel-sidebar .brand {
|
||||||
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-weight: 700; font-size: .95rem; color: #f1f5f9;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
.panel-sidebar .brand .badge-sa {
|
||||||
|
background: #ef4444; color: #fff; font-size: .6rem;
|
||||||
|
padding: .15rem .4rem; border-radius: 4px; vertical-align: middle;
|
||||||
|
margin-left: .35rem; letter-spacing: .03em;
|
||||||
|
}
|
||||||
|
.panel-sidebar nav { padding: .75rem 0; flex: 1; }
|
||||||
|
.panel-sidebar nav a {
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
padding: .5rem 1.25rem; color: #94a3b8; text-decoration: none;
|
||||||
|
font-size: .875rem; border-left: 3px solid transparent;
|
||||||
|
transition: background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.panel-sidebar nav a:hover, .panel-sidebar nav a.active {
|
||||||
|
background: #1e293b; color: #f1f5f9;
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
}
|
||||||
|
.panel-sidebar nav a i { font-size: 1rem; }
|
||||||
|
.panel-sidebar .sa-footer {
|
||||||
|
padding: .85rem 1.25rem; border-top: 1px solid #1e293b;
|
||||||
|
font-size: .78rem; color: #64748b;
|
||||||
|
}
|
||||||
|
.panel-sidebar .sa-footer a { color: #94a3b8; text-decoration: none; }
|
||||||
|
.panel-sidebar .sa-footer a:hover { color: #f1f5f9; }
|
||||||
|
.panel-main { margin-left: 220px; padding: 1.75rem 2rem; }
|
||||||
|
.panel-topbar {
|
||||||
|
background: #fff; border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: .75rem 2rem; margin-left: 220px;
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.status-badge { display: inline-block; padding: .2rem .55rem; border-radius: 12px; font-size: .72rem; font-weight: 600; }
|
||||||
|
.status-active { background: #dcfce7; color: #15803d; }
|
||||||
|
.status-suspended { background: #fef9c3; color: #92400e; }
|
||||||
|
.status-provisioning { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.status-deleted { background: #fee2e2; color: #991b1b; }
|
||||||
|
.rev-ok { color: #15803d; }
|
||||||
|
.rev-behind { color: #d97706; font-weight: 600; }
|
||||||
|
.rev-error { color: #dc2626; font-size: .78rem; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel-sidebar { display: none; }
|
||||||
|
.panel-main, .panel-topbar { margin-left: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="panel-sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<i class="bi bi-shield-lock-fill me-1 text-primary"></i>
|
||||||
|
JQC Control<span class="badge-sa">SUPER</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('tenants.list_tenants') }}"
|
||||||
|
class="{{ 'active' if request.endpoint == 'tenants.list_tenants' else '' }}">
|
||||||
|
<i class="bi bi-building"></i> Tenants
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tenants.provision_tenant') }}"
|
||||||
|
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Tenant
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sa-footer">
|
||||||
|
{% if sa_username %}
|
||||||
|
Signed in as <strong>{{ sa_username }}</strong><br>
|
||||||
|
<a href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-left me-1"></i>Logout</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-topbar">
|
||||||
|
<span class="fw-semibold text-muted" style="font-size:.9rem;">
|
||||||
|
{% block page_title %}Control Panel{% endblock %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-main">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'danger' else ('warning' if category == 'warning' else ('success' if category == 'success' else 'info')) }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Control Panel Login — JQC</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
body { background: #0f172a; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.login-card {
|
||||||
|
background: #1e293b; border-radius: 14px; padding: 2.5rem 2.25rem;
|
||||||
|
width: 100%; max-width: 380px; box-shadow: 0 20px 60px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.login-card .logo { font-size: 2rem; color: #3b82f6; }
|
||||||
|
.login-card h1 { color: #f1f5f9; font-size: 1.3rem; font-weight: 700; margin-top: .5rem; }
|
||||||
|
.login-card .sub { color: #64748b; font-size: .85rem; margin-bottom: 1.75rem; }
|
||||||
|
.login-card label { color: #94a3b8; font-size: .85rem; }
|
||||||
|
.login-card .form-control {
|
||||||
|
background: #0f172a; border-color: #334155; color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.login-card .form-control:focus {
|
||||||
|
background: #0f172a; border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25); color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.login-card .form-control::placeholder { color: #475569; }
|
||||||
|
.btn-panel { background: #3b82f6; border-color: #3b82f6; color: #fff; font-weight: 600; }
|
||||||
|
.btn-panel:hover { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||||
|
.error-box { background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5;
|
||||||
|
border-radius: 8px; padding: .65rem 1rem; font-size: .85rem; margin-bottom: 1rem; }
|
||||||
|
.footer-note { color: #334155; font-size: .72rem; text-align: center; margin-top: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo"><i class="bi bi-shield-lock-fill"></i></div>
|
||||||
|
<h1>Control Panel</h1>
|
||||||
|
<p class="sub">Superadmin access only.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-box"><i class="bi bi-exclamation-triangle me-1"></i>{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="superadmin" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-panel w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="footer-note">JQC Superadmin Panel — authorised personnel only.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}New Tenant — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Provision New Tenant{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-plus-circle me-1"></i>New Tenant</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('tenants.provision_tenant') }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" style="font-size:.85rem;">Slug <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="slug" class="form-control form-control-sm" required
|
||||||
|
pattern="^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
|
||||||
|
placeholder="e.g. acme" maxlength="63">
|
||||||
|
<div class="form-text" style="font-size:.75rem;">DNS label — lowercase letters, digits, hyphens only.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" style="font-size:.85rem;">Company Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control form-control-sm" required
|
||||||
|
placeholder="Acme Janitorial Inc." maxlength="150">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" style="font-size:.85rem;">Plan <span class="text-danger">*</span></label>
|
||||||
|
<select name="plan_code" class="form-select form-select-sm" required>
|
||||||
|
{% for p in plans %}
|
||||||
|
<option value="{{ p.code }}">{{ p.name }} ({{ p.code }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" style="font-size:.85rem;">Admin Email <span class="text-danger">*</span></label>
|
||||||
|
<input type="email" name="admin_email" class="form-control form-control-sm" required
|
||||||
|
placeholder="admin@acme.com">
|
||||||
|
<div class="form-text" style="font-size:.75rem;">Setup link will be sent to this address.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" style="font-size:.85rem;">Admin Username <span class="text-muted">(optional)</span></label>
|
||||||
|
<input type="text" name="admin_username" class="form-control form-control-sm"
|
||||||
|
placeholder="Defaults to email prefix">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" style="font-size:.85rem;">Custom Domain <span class="text-muted">(optional)</span></label>
|
||||||
|
<input type="text" name="custom_domain" class="form-control form-control-sm"
|
||||||
|
placeholder="jqc.acme.com">
|
||||||
|
<div class="form-text" style="font-size:.75rem;">Leave blank to use subdomain only.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-rocket-takeoff me-1"></i> Provision Tenant
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('tenants.list_tenants') }}" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-body py-2" style="font-size:.8rem; color:#64748b;">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Provisioning will: create MySQL DB + user, build schema, seed admin account,
|
||||||
|
register subdomain. The setup link in the success message must be sent to the admin manually.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}{{ tenant.slug }} — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Tenant: {{ tenant.name }} ({{ tenant.slug }}){% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{# ── breadcrumb ── #}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb" style="font-size:.85rem;">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('tenants.list_tenants') }}">Tenants</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ tenant.slug }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
{# ═══ LEFT COLUMN ═══ #}
|
||||||
|
<div class="col-lg-8">
|
||||||
|
|
||||||
|
{# ── Info card ── #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-building me-1"></i>Tenant Info</span>
|
||||||
|
<span class="status-badge status-{{ tenant.status }}">{{ tenant.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2" style="font-size:.85rem;">
|
||||||
|
<div class="col-6"><span class="text-muted">ID</span><div class="fw-medium">{{ tenant.id }}</div></div>
|
||||||
|
<div class="col-6"><span class="text-muted">Slug</span><div class="fw-medium">{{ tenant.slug }}</div></div>
|
||||||
|
<div class="col-6"><span class="text-muted">Name</span><div class="fw-medium">{{ tenant.name }}</div></div>
|
||||||
|
<div class="col-6"><span class="text-muted">Created</span>
|
||||||
|
<div class="fw-medium">{{ tenant.created_at.strftime('%Y-%m-%d %H:%M') if tenant.created_at else '—' }}</div></div>
|
||||||
|
{% if tenant.suspended_at %}
|
||||||
|
<div class="col-6"><span class="text-muted">Suspended</span>
|
||||||
|
<div class="text-warning fw-medium">{{ tenant.suspended_at.strftime('%Y-%m-%d %H:%M') }}</div></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-12"><span class="text-muted">DB</span>
|
||||||
|
<div class="fw-medium font-monospace" style="font-size:.78rem;">
|
||||||
|
{{ tenant.db_user }}@{{ tenant.db_host }}:{{ tenant.db_port }}/{{ tenant.db_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Migration card ── #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-database-up me-1"></i>Schema Migration</span>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.migrate_tenant', tenant_id=tenant.id) }}"
|
||||||
|
onsubmit="return confirm('Run upgrade_tenant for {{ tenant.slug }}?')">
|
||||||
|
<button class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-arrow-up-circle me-1"></i> Upgrade to Head
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2" style="font-size:.85rem;">
|
||||||
|
<div class="row g-1">
|
||||||
|
<div class="col-12">
|
||||||
|
<span class="text-muted">Chain head:</span>
|
||||||
|
<code class="ms-1">{{ chain_head }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<span class="text-muted">Cached head:</span>
|
||||||
|
<code class="ms-1">{{ tenant.alembic_head or '—' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<span class="text-muted">Live DB rev:</span>
|
||||||
|
{% if live_rev == chain_head %}
|
||||||
|
<span class="rev-ok ms-1"><i class="bi bi-check-circle-fill"></i> {{ live_rev }}</span>
|
||||||
|
{% elif live_rev and not live_rev.startswith('<error') %}
|
||||||
|
<span class="rev-behind ms-1"><i class="bi bi-exclamation-circle-fill"></i> {{ live_rev }} (BEHIND)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="rev-error ms-1">{{ live_rev }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Domains card ── #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-globe me-1"></i>Domains</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 align-middle" style="font-size:.82rem;">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Primary</th>
|
||||||
|
<th>Verified</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in domains %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace">{{ d.domain }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ d.kind }}</span></td>
|
||||||
|
<td>{% if d.is_primary %}<i class="bi bi-check-circle-fill text-success"></i>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if d.verified %}
|
||||||
|
<span class="text-success"><i class="bi bi-check-circle-fill"></i></span>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.verify_domain', tenant_id=tenant.id, domain_id=d.id) }}"
|
||||||
|
class="d-inline">
|
||||||
|
<button class="btn btn-xs btn-outline-success" style="font-size:.72rem; padding:.1rem .4rem;">
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge {% if d.tls_status == 'active' %}bg-success{% elif d.tls_status == 'failed' %}bg-danger{% else %}bg-secondary{% endif %}">{{ d.tls_status }}</span></td>
|
||||||
|
<td class="font-monospace" style="font-size:.72rem; max-width:120px; overflow:hidden; text-overflow:ellipsis;">{{ d.verification_token or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if not d.is_primary %}
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.delete_domain', tenant_id=tenant.id, domain_id=d.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete domain {{ d.domain }}?')">
|
||||||
|
<button class="btn btn-xs btn-outline-danger" style="font-size:.72rem; padding:.1rem .4rem;">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-muted py-2">No domains.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white py-2">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.add_domain', tenant_id=tenant.id) }}"
|
||||||
|
class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="text" name="domain" class="form-control form-control-sm"
|
||||||
|
placeholder="custom.example.com" style="width:220px;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="kind" class="form-select form-select-sm">
|
||||||
|
<option value="custom">custom</option>
|
||||||
|
<option value="subdomain">subdomain</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add Domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Provisioning jobs ── #}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-list-check me-1"></i>Recent Jobs</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 align-middle" style="font-size:.8rem;">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th><th>Action</th><th>Status</th><th>Created</th><th>Log</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for j in jobs %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ j.id }}</td>
|
||||||
|
<td>{{ j.action }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if j.status == 'ok' %}bg-success{% elif j.status == 'failed' %}bg-danger{% elif j.status == 'running' %}bg-info{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ j.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ j.created_at.strftime('%m-%d %H:%M') if j.created_at else '—' }}</td>
|
||||||
|
<td class="text-muted" style="max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||||
|
{{ j.log or '' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-muted py-2">No jobs recorded.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{# /col-lg-8 #}
|
||||||
|
|
||||||
|
{# ═══ RIGHT COLUMN ═══ #}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
|
||||||
|
{# ── Plan card ── #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-patch-check me-1"></i>Plan</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.change_plan', tenant_id=tenant.id) }}">
|
||||||
|
<div class="mb-2">
|
||||||
|
<select name="plan_id" class="form-select form-select-sm">
|
||||||
|
{% for p in plans %}
|
||||||
|
<option value="{{ p.id }}" {{ 'selected' if p.id == tenant.plan_id else '' }}>
|
||||||
|
{{ p.name }} ({{ p.code }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary w-100">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i> Change Plan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Status actions ── #}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-toggle-on me-1"></i>Status Actions</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2 d-flex flex-column gap-2">
|
||||||
|
|
||||||
|
{# Impersonate #}
|
||||||
|
{% if tenant.status == 'active' %}
|
||||||
|
<a href="{{ url_for('tenants.impersonate', tenant_id=tenant.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="return confirm('Open tenant {{ tenant.slug }} as superadmin? You will be redirected to the tenant app.')">
|
||||||
|
<i class="bi bi-person-badge me-1"></i> Impersonate
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Suspend #}
|
||||||
|
{% if tenant.status not in ('suspended', 'deleted') %}
|
||||||
|
<button class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#suspendModal">
|
||||||
|
<i class="bi bi-pause-circle me-1"></i> Suspend
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Resume #}
|
||||||
|
{% if tenant.status == 'suspended' %}
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('tenants.resume_tenant', tenant_id=tenant.id) }}"
|
||||||
|
onsubmit="return confirm('Resume tenant {{ tenant.slug }}?')">
|
||||||
|
<button class="btn btn-sm btn-outline-success w-100">
|
||||||
|
<i class="bi bi-play-circle me-1"></i> Resume
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Quick info ── #}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-link-45deg me-1"></i>Primary URL</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2" style="font-size:.82rem;">
|
||||||
|
{% set primary_domain = domains | selectattr('is_primary') | first %}
|
||||||
|
{% if primary_domain %}
|
||||||
|
<a href="https://{{ primary_domain.domain }}" target="_blank" rel="noopener"
|
||||||
|
class="text-decoration-none font-monospace">
|
||||||
|
https://{{ primary_domain.domain }}
|
||||||
|
<i class="bi bi-box-arrow-up-right ms-1 text-muted" style="font-size:.75rem;"></i>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No primary domain.</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{# /col-lg-4 #}
|
||||||
|
</div>{# /row #}
|
||||||
|
|
||||||
|
{# ── Suspend modal ── #}
|
||||||
|
<div class="modal fade" id="suspendModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" style="font-size:.9rem;">Suspend {{ tenant.slug }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('tenants.suspend_tenant', tenant_id=tenant.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label" style="font-size:.85rem;">Reason (optional)</label>
|
||||||
|
<input type="text" name="reason" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. non-payment" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-sm btn-warning">Suspend</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "panel/base.html" %}
|
||||||
|
{% block title %}Tenants — JQC Control{% endblock %}
|
||||||
|
{% block page_title %}Tenants{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted" style="font-size:.85rem;">
|
||||||
|
Chain head: <code>{{ chain_head }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('tenants.provision_tenant') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> New Tenant
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px">#</th>
|
||||||
|
<th>Slug / Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Schema</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tenants %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted" style="font-size:.8rem;">{{ t.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold" style="font-size:.9rem;">{{ t.slug }}</div>
|
||||||
|
<div class="text-muted" style="font-size:.78rem;">{{ t.name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ t.status }}">{{ t.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:.85rem;">{{ t.plan_name }}</td>
|
||||||
|
<td style="font-size:.78rem; font-family: monospace;">
|
||||||
|
{% if t.alembic_head == chain_head %}
|
||||||
|
<span class="rev-ok"><i class="bi bi-check-circle-fill me-1"></i>current</span>
|
||||||
|
{% elif t.alembic_head %}
|
||||||
|
<span class="rev-behind"><i class="bi bi-exclamation-circle-fill me-1"></i>{{ t.alembic_head[:12] }}…</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:.78rem; color:#64748b;">
|
||||||
|
{{ t.created_at.strftime('%Y-%m-%d') if t.created_at else '—' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tenants.tenant_detail', tenant_id=t.id) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No tenants yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
control/panel/tenants.py
|
||||||
|
-------------------------
|
||||||
|
Superadmin control panel — tenant management routes (MT-4).
|
||||||
|
|
||||||
|
Routes
|
||||||
|
------
|
||||||
|
GET /tenants/ — list all tenants
|
||||||
|
GET /tenants/<id> — tenant detail + migration status
|
||||||
|
POST /tenants/<id>/plan — change plan
|
||||||
|
POST /tenants/<id>/suspend — suspend
|
||||||
|
POST /tenants/<id>/resume — resume
|
||||||
|
GET /tenants/<id>/impersonate — generate token + redirect into tenant
|
||||||
|
POST /tenants/<id>/domain/add — add custom domain
|
||||||
|
POST /tenants/<id>/domain/<d_id>/verify — mark domain verified
|
||||||
|
POST /tenants/<id>/domain/<d_id>/delete — remove domain
|
||||||
|
POST /tenants/provision — create new tenant (calls provision.create_tenant)
|
||||||
|
POST /tenants/<id>/migrate — run upgrade_tenant for this tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint, render_template, redirect, url_for,
|
||||||
|
request, session, flash,
|
||||||
|
)
|
||||||
|
|
||||||
|
from control.base import control_session
|
||||||
|
from control.models import Plan, Tenant, TenantDomain, TenantAudit, ProvisioningJob
|
||||||
|
from control.time_utils import now_eastern
|
||||||
|
from control.tenant_migrate import upgrade_tenant, current_revision, chain_head, TenantRef
|
||||||
|
from .decorators import superadmin_required
|
||||||
|
from .impersonate import generate_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('tenants', __name__, url_prefix='/tenants')
|
||||||
|
|
||||||
|
_BASE_DOMAIN = lambda: os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
|
||||||
|
|
||||||
|
|
||||||
|
# ── audit helper ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _audit(action, tenant_id=None, details=None):
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
try:
|
||||||
|
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||||
|
or request.remote_addr)
|
||||||
|
with control_session() as s:
|
||||||
|
s.add(TenantAudit(
|
||||||
|
superadmin_id=sa_id,
|
||||||
|
action=action[:50],
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=(ip or '')[:45],
|
||||||
|
created_at=now_eastern(),
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('TenantAudit write failed: %s', exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── list ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@superadmin_required
|
||||||
|
def list_tenants():
|
||||||
|
with control_session() as s:
|
||||||
|
tenants = (s.query(Tenant)
|
||||||
|
.order_by(Tenant.id)
|
||||||
|
.all())
|
||||||
|
plans = {p.id: p for p in s.query(Plan).all()}
|
||||||
|
# Detach: collect plain dicts so session can close safely.
|
||||||
|
rows = []
|
||||||
|
for t in tenants:
|
||||||
|
rows.append({
|
||||||
|
'id': t.id, 'slug': t.slug, 'name': t.name,
|
||||||
|
'status': t.status,
|
||||||
|
'plan_name': plans[t.plan_id].name if t.plan_id in plans else '?',
|
||||||
|
'alembic_head': t.alembic_head,
|
||||||
|
'created_at': t.created_at,
|
||||||
|
})
|
||||||
|
head = chain_head()
|
||||||
|
return render_template('panel/tenants_list.html',
|
||||||
|
tenants=rows, chain_head=head,
|
||||||
|
sa_username=session.get('sa_username'))
|
||||||
|
|
||||||
|
|
||||||
|
# ── detail ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>')
|
||||||
|
@superadmin_required
|
||||||
|
def tenant_detail(tenant_id):
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
|
||||||
|
plans = s.query(Plan).order_by(Plan.id).all()
|
||||||
|
domains = list(t.domains) # load while session open
|
||||||
|
jobs = (s.query(ProvisioningJob)
|
||||||
|
.filter_by(tenant_id=tenant_id)
|
||||||
|
.order_by(ProvisioningJob.id.desc())
|
||||||
|
.limit(20)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
# Live revision from DB (may differ from cached tenants.alembic_head)
|
||||||
|
try:
|
||||||
|
live_rev = current_revision(t.db_uri)
|
||||||
|
except Exception as e:
|
||||||
|
live_rev = f'<error: {e}>'
|
||||||
|
|
||||||
|
head = chain_head()
|
||||||
|
|
||||||
|
info = {
|
||||||
|
'id': t.id, 'slug': t.slug, 'name': t.name, 'status': t.status,
|
||||||
|
'plan_id': t.plan_id, 'db_name': t.db_name, 'db_user': t.db_user,
|
||||||
|
'db_host': t.db_host, 'db_port': t.db_port,
|
||||||
|
'alembic_head': t.alembic_head, 'created_at': t.created_at,
|
||||||
|
'suspended_at': t.suspended_at, 'notes': t.notes,
|
||||||
|
}
|
||||||
|
plan_rows = [{'id': p.id, 'code': p.code, 'name': p.name} for p in plans]
|
||||||
|
domain_rows = [
|
||||||
|
{'id': d.id, 'domain': d.domain, 'kind': d.kind,
|
||||||
|
'is_primary': d.is_primary, 'verified': d.verified,
|
||||||
|
'tls_status': d.tls_status, 'verification_token': d.verification_token}
|
||||||
|
for d in domains
|
||||||
|
]
|
||||||
|
job_rows = [
|
||||||
|
{'id': j.id, 'action': j.action, 'status': j.status,
|
||||||
|
'created_at': j.created_at, 'finished_at': j.finished_at,
|
||||||
|
'log': j.log}
|
||||||
|
for j in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'panel/tenant_detail.html',
|
||||||
|
tenant=info, plans=plan_rows, domains=domain_rows,
|
||||||
|
jobs=job_rows, live_rev=live_rev, chain_head=head,
|
||||||
|
sa_username=session.get('sa_username'),
|
||||||
|
base_domain=_BASE_DOMAIN(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── plan change ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/plan', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def change_plan(tenant_id):
|
||||||
|
plan_id = request.form.get('plan_id', type=int)
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
plan = s.get(Plan, plan_id)
|
||||||
|
if plan is None:
|
||||||
|
flash('Invalid plan.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
old_plan_id = t.plan_id
|
||||||
|
t.plan_id = plan_id
|
||||||
|
_audit('PLAN_CHANGE', tenant_id=tenant_id,
|
||||||
|
details=f'plan_id {old_plan_id} → {plan_id} ({plan.code})')
|
||||||
|
logger.info('PANEL | plan_change | tenant=%s old=%s new=%s(%s)',
|
||||||
|
tenant_id, old_plan_id, plan_id, plan.code)
|
||||||
|
flash(f'Plan updated to {plan.name}.', 'success')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── suspend / resume ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/suspend', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def suspend_tenant(tenant_id):
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
if t.status == 'suspended':
|
||||||
|
flash('Already suspended.', 'info')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
reason = (request.form.get('reason') or '').strip()[:255]
|
||||||
|
t.status = 'suspended'
|
||||||
|
t.suspended_at = now_eastern()
|
||||||
|
s.add(ProvisioningJob(
|
||||||
|
tenant_id=tenant_id, action='suspend', status='ok',
|
||||||
|
created_at=now_eastern(), finished_at=now_eastern(),
|
||||||
|
log=f'suspended by superadmin (reason: {reason or "none"})'))
|
||||||
|
_audit('SUSPEND', tenant_id=tenant_id, details=f'reason={reason}')
|
||||||
|
logger.info('PANEL | suspend | tenant=%s reason=%s', tenant_id, reason)
|
||||||
|
flash('Tenant suspended.', 'warning')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/resume', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def resume_tenant(tenant_id):
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
if t.status != 'suspended':
|
||||||
|
flash('Tenant is not suspended.', 'info')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
t.status = 'active'
|
||||||
|
t.suspended_at = None
|
||||||
|
s.add(ProvisioningJob(
|
||||||
|
tenant_id=tenant_id, action='resume', status='ok',
|
||||||
|
created_at=now_eastern(), finished_at=now_eastern(),
|
||||||
|
log='resumed by superadmin'))
|
||||||
|
_audit('RESUME', tenant_id=tenant_id)
|
||||||
|
logger.info('PANEL | resume | tenant=%s', tenant_id)
|
||||||
|
flash('Tenant resumed.', 'success')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── impersonation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/impersonate')
|
||||||
|
@superadmin_required
|
||||||
|
def impersonate(tenant_id):
|
||||||
|
"""Generate a short-lived signed token and redirect into the tenant app."""
|
||||||
|
sa_id = session.get('sa_id')
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
if t.status != 'active':
|
||||||
|
flash('Can only impersonate active tenants.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
# Find the primary domain.
|
||||||
|
primary = next(
|
||||||
|
(d.domain for d in t.domains if d.is_primary and d.verified),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if primary is None:
|
||||||
|
flash('Tenant has no verified primary domain.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
token = generate_token(tenant_id, sa_id)
|
||||||
|
_audit('IMPERSONATE', tenant_id=tenant_id,
|
||||||
|
details=f'superadmin_id={sa_id} target_domain={primary}')
|
||||||
|
logger.info('PANEL | impersonate | sa=%s tenant=%s domain=%s',
|
||||||
|
sa_id, tenant_id, primary)
|
||||||
|
|
||||||
|
# Redirect into the main app's impersonation endpoint.
|
||||||
|
target = f'https://{primary}/auth/impersonate?token={token}'
|
||||||
|
return redirect(target)
|
||||||
|
|
||||||
|
|
||||||
|
# ── domain management ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/domain/add', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def add_domain(tenant_id):
|
||||||
|
import secrets as _secrets
|
||||||
|
domain = (request.form.get('domain') or '').strip().lower()
|
||||||
|
kind = request.form.get('kind', 'custom')
|
||||||
|
if not domain:
|
||||||
|
flash('Domain is required.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
existing = s.query(TenantDomain).filter_by(domain=domain).first()
|
||||||
|
if existing:
|
||||||
|
flash(f'Domain {domain} already registered.', 'warning')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
s.add(TenantDomain(
|
||||||
|
tenant_id=tenant_id, domain=domain,
|
||||||
|
kind=kind, is_primary=False, verified=(kind == 'subdomain'),
|
||||||
|
verification_token=_secrets.token_hex(16),
|
||||||
|
tls_status='pending', created_at=now_eastern(),
|
||||||
|
))
|
||||||
|
_audit('DOMAIN_ADD', tenant_id=tenant_id, details=f'domain={domain} kind={kind}')
|
||||||
|
flash(f'Domain {domain} added.', 'success')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/domain/<int:domain_id>/verify', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def verify_domain(tenant_id, domain_id):
|
||||||
|
with control_session() as s:
|
||||||
|
d = s.get(TenantDomain, domain_id)
|
||||||
|
if d is None or d.tenant_id != tenant_id:
|
||||||
|
flash('Domain not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
d.verified = True
|
||||||
|
d.tls_status = 'active'
|
||||||
|
_audit('DOMAIN_VERIFY', tenant_id=tenant_id,
|
||||||
|
details=f'domain_id={domain_id} domain={d.domain}')
|
||||||
|
flash(f'Domain {d.domain} marked verified.', 'success')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/domain/<int:domain_id>/delete', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def delete_domain(tenant_id, domain_id):
|
||||||
|
with control_session() as s:
|
||||||
|
d = s.get(TenantDomain, domain_id)
|
||||||
|
if d is None or d.tenant_id != tenant_id:
|
||||||
|
flash('Domain not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
if d.is_primary:
|
||||||
|
flash('Cannot delete the primary domain.', 'danger')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
domain_str = d.domain
|
||||||
|
s.delete(d)
|
||||||
|
_audit('DOMAIN_DELETE', tenant_id=tenant_id, details=f'domain={domain_str}')
|
||||||
|
flash(f'Domain {domain_str} removed.', 'success')
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ── provision new tenant ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/provision', methods=['GET', 'POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def provision_tenant():
|
||||||
|
if request.method == 'GET':
|
||||||
|
with control_session() as s:
|
||||||
|
plans = [{'id': p.id, 'code': p.code, 'name': p.name}
|
||||||
|
for p in s.query(Plan).filter_by(active=True).order_by(Plan.id).all()]
|
||||||
|
return render_template('panel/provision.html',
|
||||||
|
plans=plans,
|
||||||
|
sa_username=session.get('sa_username'))
|
||||||
|
|
||||||
|
# POST — provision
|
||||||
|
slug = (request.form.get('slug') or '').strip().lower()
|
||||||
|
name = (request.form.get('name') or '').strip()
|
||||||
|
plan_code = (request.form.get('plan_code') or '').strip()
|
||||||
|
admin_email = (request.form.get('admin_email') or '').strip()
|
||||||
|
admin_username = (request.form.get('admin_username') or '').strip() or None
|
||||||
|
custom_domain = (request.form.get('custom_domain') or '').strip() or None
|
||||||
|
|
||||||
|
if not (slug and name and plan_code and admin_email):
|
||||||
|
flash('Slug, name, plan, and admin email are required.', 'danger')
|
||||||
|
return redirect(url_for('tenants.provision_tenant'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from control.provision import create_tenant
|
||||||
|
info = create_tenant(
|
||||||
|
slug=slug, name=name, plan_code=plan_code,
|
||||||
|
admin_email=admin_email, admin_username=admin_username,
|
||||||
|
custom_domain=custom_domain,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
flash(str(e), 'danger')
|
||||||
|
return redirect(url_for('tenants.provision_tenant'))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('PANEL | provision_fail | slug=%s error=%s', slug, e, exc_info=True)
|
||||||
|
flash(f'Provisioning failed: {e}', 'danger')
|
||||||
|
return redirect(url_for('tenants.provision_tenant'))
|
||||||
|
|
||||||
|
_audit('PROVISION', tenant_id=info['tenant_id'],
|
||||||
|
details=f'slug={slug} plan={plan_code} admin={admin_email}')
|
||||||
|
logger.info('PANEL | provisioned | tenant=%s id=%s', slug, info['tenant_id'])
|
||||||
|
flash(
|
||||||
|
f'Tenant "{name}" ({slug}) provisioned. '
|
||||||
|
f'Admin setup link: {info["setup_link"]}',
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=info['tenant_id']))
|
||||||
|
|
||||||
|
|
||||||
|
# ── run migration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])
|
||||||
|
@superadmin_required
|
||||||
|
def migrate_tenant(tenant_id):
|
||||||
|
with control_session() as s:
|
||||||
|
t = s.get(Tenant, tenant_id)
|
||||||
|
if t is None:
|
||||||
|
flash('Tenant not found.', 'danger')
|
||||||
|
return redirect(url_for('tenants.list_tenants'))
|
||||||
|
ref = TenantRef(t.id, t.slug, t.db_uri)
|
||||||
|
|
||||||
|
try:
|
||||||
|
applied = upgrade_tenant(ref)
|
||||||
|
_audit('MIGRATE', tenant_id=tenant_id, details=f'head={applied}')
|
||||||
|
flash(f'Migration complete → {applied}', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('PANEL | migrate_fail | tenant=%s error=%s', tenant_id, e)
|
||||||
|
flash(f'Migration failed: {e}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
wsgi_panel.py
|
||||||
|
-------------
|
||||||
|
WSGI entry point for the JQC superadmin control panel (MT-4).
|
||||||
|
|
||||||
|
Serve with a dedicated Gunicorn process + systemd unit:
|
||||||
|
gunicorn --workers 2 --bind 127.0.0.1:5001 wsgi_panel:panel_app
|
||||||
|
|
||||||
|
Nginx proxies admin.jqc.app → 127.0.0.1:5001 (see deploy notes in CLAUDE.md).
|
||||||
|
|
||||||
|
Required env vars (same /etc/jqc/control.env loaded by the main app):
|
||||||
|
CONTROL_DATABASE_URL
|
||||||
|
CONTROL_FERNET_KEY
|
||||||
|
PANEL_SECRET_KEY
|
||||||
|
PANEL_IMPERSONATE_KEY
|
||||||
|
TENANT_BASE_DOMAIN
|
||||||
|
"""
|
||||||
|
|
||||||
|
from control.panel import create_panel_app
|
||||||
|
|
||||||
|
panel_app = create_panel_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
panel_app.run(host='127.0.0.1', port=5001, debug=False)
|
||||||
Reference in New Issue
Block a user