Showcase Mode: full read-only admin access for client demos

New mode: Showcase (replaces basic demo mode for client demos)
- Visitors see EVERYTHING: Admin, Monitor, Logs, Threat Intel,
  Lab, History, Meta-Pipelines — all without logging in
- Read-only: all GET requests allowed on all routes
- Allowed POSTs: team runs, self-analysis, IP enrichment
  (read-like operations that don't modify system config)
- Blocked POSTs: config changes, bans, deletes, bulk archive

Admin UI (Security tab):
- "Enable Showcase" button (magenta) — one click to activate
- "Turn Off" button appears when active
- Clear description of what visitors can and can't do
- Status shows "SHOWCASE MODE" with magenta styling

Banner:
- Magenta gradient banner on all pages when showcase is active
- Shows: "Showcase Mode — Full Read-Only Access — Admin · Monitor · Logs · Lab · History"
- Demo button in nav shows "Showcase" in magenta

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-26 05:19:41 -05:00
parent dfab02f114
commit 9f48a050c8

View File

@ -70,13 +70,18 @@ LOGIN_RATE_MAX = 5
# IPs that never get rate-limited (your LAN, localhost)
ALLOWLIST_IPS = {"127.0.0.1", "::1", "192.168.1.1"}
# Demo mode state — toggled by admin at runtime
_demo_mode = {"active": False, "started_by": None}
_demo_mode = {"active": False, "started_by": None, "showcase": False}
# Admin-only write routes — blocked in demo for non-admin users
ADMIN_WRITE_ROUTES = {
"/api/admin/config": ["POST"],
"/api/admin/test-provider": ["POST"],
"/api/auth/login": ["POST"],
# Routes that demo users CAN trigger (read-like POSTs — enrichment, self-analysis, team runs)
DEMO_ALLOWED_POSTS = {
"/api/run", "/api/self-analyze", "/api/admin/security/enrich",
}
# Routes that demo users CANNOT touch (destructive writes)
DEMO_BLOCKED_POSTS = {
"/api/admin/config", "/api/admin/test-provider", "/api/admin/security/ban",
"/api/admin/security/mass-ban", "/api/demo/toggle", "/api/demo/allowlist",
"/api/runs/bulk-archive", "/api/meta-pipeline",
}
@ -123,10 +128,14 @@ def login_required(f):
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
# Demo mode: allow read access (GET), block writes unless admin
# Demo/showcase mode: full read access to everything
if is_demo():
if request.method == "GET":
return f(*args, **kwargs)
# Allow specific read-like POSTs (enrichment, self-analysis, team runs)
if request.path in DEMO_ALLOWED_POSTS:
return f(*args, **kwargs)
# Block destructive writes
if not is_admin():
return jsonify({"error": "demo mode: read-only", "demo": True}), 403
if not session.get("user_id"):
@ -444,16 +453,28 @@ def logout_page():
@app.route("/api/demo/status")
def demo_status():
return jsonify({"active": is_demo(), "started_by": _demo_mode.get("started_by")})
return jsonify({"active": is_demo(), "showcase": _demo_mode.get("showcase", False), "started_by": _demo_mode.get("started_by")})
@app.route("/api/demo/toggle", methods=["POST"])
def demo_toggle():
if not session.get("user_id") or not is_admin():
return jsonify({"error": "admin only"}), 403
data = request.json if request.is_json else {}
mode = data.get("mode", "toggle") # toggle, showcase, off
if mode == "off":
_demo_mode["active"] = False
_demo_mode["showcase"] = False
_demo_mode["started_by"] = None
elif mode == "showcase":
_demo_mode["active"] = True
_demo_mode["showcase"] = True
_demo_mode["started_by"] = session.get("username")
else:
_demo_mode["active"] = not _demo_mode["active"]
_demo_mode["showcase"] = _demo_mode["active"]
_demo_mode["started_by"] = session.get("username") if _demo_mode["active"] else None
return jsonify({"active": _demo_mode["active"]})
return jsonify({"active": _demo_mode["active"], "showcase": _demo_mode["showcase"]})
@app.route("/api/demo/allowlist", methods=["GET"])
@ -2707,27 +2728,42 @@ async function deleteRun(id) {
// DEMO MODE
async function checkDemo() {
try {
const r = await fetch('/api/demo/status');
const d = await r.json();
updateDemoUI(d.active);
var r = await fetch('/api/demo/status');
var d = await r.json();
updateDemoUI(d.active, d.showcase);
} catch(e) {}
}
function updateDemoUI(active) {
const btn = document.getElementById('demo-toggle');
const banner = document.getElementById('demo-banner');
function updateDemoUI(active, showcase) {
var btn = document.getElementById('demo-toggle');
var banner = document.getElementById('demo-banner');
if (btn) {
btn.style.display = '';
btn.textContent = active ? 'Demo ON' : 'Demo';
btn.style.color = active ? '#22c55e' : 'var(--orange)';
btn.style.borderColor = active ? 'rgba(34,197,94,0.4)' : 'rgba(245,158,11,0.3)';
if (showcase) {
btn.textContent = 'Showcase';
btn.style.color = '#d946ef';
btn.style.borderColor = 'rgba(217,70,239,0.4)';
} else if (active) {
btn.textContent = 'Demo ON';
btn.style.color = '#4ade80';
btn.style.borderColor = 'rgba(74,222,128,0.4)';
} else {
btn.textContent = 'Demo';
btn.style.color = 'var(--orange)';
btn.style.borderColor = 'rgba(245,158,11,0.3)';
}
}
if (active) {
if (!banner) {
const b = document.createElement('div');
var b = document.createElement('div');
b.id = 'demo-banner';
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(34,197,94,0.08),rgba(34,197,94,0.15),rgba(34,197,94,0.08));border-bottom:1px solid rgba(34,197,94,0.25);color:#22c55e;text-align:center;font-size:12px;padding:6px;z-index:50;font-weight:600;letter-spacing:1px';
b.textContent = 'DEMO MODE';
if (showcase) {
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(217,70,239,0.05),rgba(217,70,239,0.12),rgba(217,70,239,0.05));border-bottom:2px solid rgba(217,70,239,0.3);color:#d946ef;text-align:center;font-size:11px;padding:8px;z-index:50;font-weight:700;letter-spacing:2px;font-family:JetBrains Mono,monospace;text-transform:uppercase';
b.textContent = 'Showcase Mode — Full Read-Only Access — Admin · Monitor · Logs · Lab · History';
} else {
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,rgba(74,222,128,0.05),rgba(74,222,128,0.1),rgba(74,222,128,0.05));border-bottom:1px solid rgba(74,222,128,0.25);color:#4ade80;text-align:center;font-size:11px;padding:6px;z-index:50;font-weight:600;letter-spacing:1px;font-family:JetBrains Mono,monospace;text-transform:uppercase';
b.textContent = 'Demo Mode';
}
document.body.prepend(b);
}
} else if (banner) {
@ -2963,11 +2999,14 @@ ADMIN_HTML = r"""
<!-- DEMO & SECURITY TAB -->
<div id="tab-security" class="tab-content">
<div class="card">
<h3>Demo Mode
<button class="btn" id="admin-demo-btn" style="margin-left:auto" onclick="adminToggleDemo()">Enable Demo</button>
<div class="card" style="border-color:rgba(217,70,239,0.3)">
<h3 style="color:#d946ef">Showcase Mode
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn" id="admin-showcase-btn" onclick="toggleShowcase()">Enable Showcase</button>
<button class="btn btn-r" id="admin-demo-off" onclick="demoOff()" style="display:none">Turn Off</button>
</div>
</h3>
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">When active, the public can view and use the Team UI, Lab, and all modes without logging in. Admin settings (API keys, config saves) are read-only for non-admins.</p>
<p style="font-size:12px;color:var(--text2);margin-bottom:10px"><strong>Showcase mode</strong> gives visitors full read-only access to everything Admin, Monitor, Logs, Threat Intel, Lab, History. They can run teams, view enrichments, and trigger self-analysis reports. They <strong>cannot</strong> change settings, ban IPs, delete data, or modify configs. Perfect for demos.</p>
<div id="demo-status-admin" style="font-size:13px">Status: <strong style="color:var(--text2)">Off</strong></div>
</div>
<div class="card">
@ -3210,25 +3249,41 @@ function switchTab(name) {
}
async function loadDemoStatus() {
const r = await fetch('/api/demo/status');
const d = await r.json();
const btn = document.getElementById('admin-demo-btn');
const st = document.getElementById('demo-status-admin');
if (d.active) {
btn.textContent = 'Disable Demo';
btn.className = 'btn btn-r';
st.innerHTML = 'Status: <strong style="color:var(--green)">ON</strong>' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
} else {
btn.textContent = 'Enable Demo';
var r = await fetch('/api/demo/status');
var d = await r.json();
var btn = document.getElementById('admin-showcase-btn');
var offBtn = document.getElementById('admin-demo-off');
var st = document.getElementById('demo-status-admin');
if (d.active && d.showcase) {
btn.textContent = 'Showcase Active';
btn.className = 'btn';
btn.style.cssText = 'margin-left:auto;border-color:#d946ef;color:#d946ef';
offBtn.style.display = '';
st.innerHTML = 'Status: <strong style="color:#d946ef">SHOWCASE MODE</strong> — full read-only access for visitors' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
} else if (d.active) {
btn.textContent = 'Demo Active';
btn.className = 'btn btn-g';
offBtn.style.display = '';
st.innerHTML = 'Status: <strong style="color:var(--green)">DEMO ON</strong>' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
} else {
btn.textContent = 'Enable Showcase';
btn.className = 'btn';
btn.style.cssText = 'border-color:rgba(217,70,239,0.3);color:#d946ef';
offBtn.style.display = 'none';
st.innerHTML = 'Status: <strong style="color:var(--text2)">Off</strong>';
}
}
async function adminToggleDemo() {
await fetch('/api/demo/toggle', {method:'POST'});
async function toggleShowcase() {
await fetch('/api/demo/toggle', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({mode:'showcase'})});
loadDemoStatus();
toast('Demo mode toggled');
toast('Showcase mode enabled', true);
}
async function demoOff() {
await fetch('/api/demo/toggle', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({mode:'off'})});
loadDemoStatus();
toast('Demo mode disabled', true);
}
async function loadAllowlist() {