Add demo mode + IP allowlist + admin security tab
- Demo mode toggle: admin can enable public access without login - Demo users can view/run everything but cannot modify admin settings - Admin write routes (config saves, API keys) blocked for non-admins in demo - IP allowlist: LAN (192.168.1.*) and localhost never rate-limited - Admin panel gets Security tab: demo toggle, allowlist management - Main UI shows "Demo ON" button (green) + top banner when active - Demo mode state is in-memory, resets on restart (safe default) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
189e8fb99b
commit
211e11b718
200
llm_team_ui.py
200
llm_team_ui.py
@ -19,15 +19,33 @@ from functools import wraps
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
|
||||
|
||||
# ─── AUTH ─────────────────────────────────────────────────────
|
||||
# ─── AUTH + DEMO MODE ─────────────────────────────────────────
|
||||
|
||||
_rate_limit = {} # ip -> (count, window_start)
|
||||
RATE_LIMIT_WINDOW = 60 # seconds
|
||||
RATE_LIMIT_MAX = 60 # requests per window
|
||||
LOGIN_RATE_MAX = 5 # login attempts per window
|
||||
RATE_LIMIT_WINDOW = 60
|
||||
RATE_LIMIT_MAX = 60
|
||||
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}
|
||||
|
||||
# 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"],
|
||||
}
|
||||
|
||||
|
||||
def is_allowlisted(ip):
|
||||
return ip in ALLOWLIST_IPS or ip.startswith("192.168.1.")
|
||||
|
||||
|
||||
def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
||||
if is_allowlisted(ip):
|
||||
return False
|
||||
now = time.time()
|
||||
if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW:
|
||||
_rate_limit[ip] = (1, now)
|
||||
@ -39,10 +57,21 @@ def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
||||
return False
|
||||
|
||||
|
||||
def is_admin():
|
||||
return session.get("role") == "admin"
|
||||
|
||||
|
||||
def is_demo():
|
||||
return _demo_mode["active"]
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("user_id"):
|
||||
# Demo mode: everyone gets in
|
||||
if is_demo() and not session.get("user_id"):
|
||||
session["demo_user"] = True
|
||||
if not session.get("user_id") and not is_demo():
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
return redirect("/login")
|
||||
@ -53,7 +82,15 @@ def login_required(f):
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# Demo mode: allow read access (GET), block writes unless admin
|
||||
if is_demo():
|
||||
if request.method == "GET":
|
||||
return f(*args, **kwargs)
|
||||
if not is_admin():
|
||||
return jsonify({"error": "demo mode: read-only", "demo": True}), 403
|
||||
if not session.get("user_id"):
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
return redirect("/login")
|
||||
if session.get("role") != "admin":
|
||||
return "Forbidden", 403
|
||||
@ -64,14 +101,19 @@ def admin_required(f):
|
||||
@app.before_request
|
||||
def security_checks():
|
||||
ip = request.remote_addr
|
||||
# Rate limit
|
||||
# Rate limit (allowlisted IPs skip)
|
||||
if rate_limited(ip):
|
||||
return jsonify({"error": "rate limited"}), 429
|
||||
# Allow login/static without auth
|
||||
if request.path in ("/login", "/api/auth/login", "/api/auth/setup"):
|
||||
# Always allow these
|
||||
if request.path in ("/login", "/api/auth/login", "/api/auth/setup", "/api/demo/status"):
|
||||
return
|
||||
if request.path.startswith("/static"):
|
||||
return
|
||||
# In demo mode, block admin write routes for non-admins
|
||||
if is_demo() and not is_admin():
|
||||
for route, methods in ADMIN_WRITE_ROUTES.items():
|
||||
if request.path == route and request.method in methods:
|
||||
return jsonify({"error": "demo mode: admin settings are read-only", "demo": True}), 403
|
||||
|
||||
|
||||
@app.after_request
|
||||
@ -238,6 +280,44 @@ def logout_page():
|
||||
session.clear()
|
||||
return redirect("/login")
|
||||
|
||||
|
||||
@app.route("/api/demo/status")
|
||||
def demo_status():
|
||||
return jsonify({"active": is_demo(), "started_by": _demo_mode.get("started_by")})
|
||||
|
||||
|
||||
@app.route("/api/demo/toggle", methods=["POST"])
|
||||
def demo_toggle():
|
||||
if not is_admin():
|
||||
return jsonify({"error": "admin only"}), 403
|
||||
_demo_mode["active"] = not _demo_mode["active"]
|
||||
_demo_mode["started_by"] = session.get("username") if _demo_mode["active"] else None
|
||||
return jsonify({"active": _demo_mode["active"]})
|
||||
|
||||
|
||||
@app.route("/api/demo/allowlist", methods=["GET"])
|
||||
def demo_get_allowlist():
|
||||
if not is_admin():
|
||||
return jsonify({"error": "admin only"}), 403
|
||||
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
||||
|
||||
|
||||
@app.route("/api/demo/allowlist", methods=["POST"])
|
||||
def demo_set_allowlist():
|
||||
if not is_admin():
|
||||
return jsonify({"error": "admin only"}), 403
|
||||
data = request.json or {}
|
||||
ip = data.get("ip", "").strip()
|
||||
action = data.get("action", "add")
|
||||
if not ip:
|
||||
return jsonify({"error": "ip required"}), 400
|
||||
if action == "add":
|
||||
ALLOWLIST_IPS.add(ip)
|
||||
elif action == "remove" and ip in ALLOWLIST_IPS:
|
||||
ALLOWLIST_IPS.discard(ip)
|
||||
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
||||
|
||||
|
||||
CONFIG_PATH = "/root/llm_team_config.json"
|
||||
DEFAULT_CONFIG = {
|
||||
"providers": {
|
||||
@ -461,6 +541,7 @@ HTML = r"""
|
||||
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;cursor:pointer;">History</button>
|
||||
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(34,197,94,0.3);border-radius:6px;">Lab</a>
|
||||
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;">Admin</a>
|
||||
<button id="demo-toggle" onclick="toggleDemo()" style="display:none;color:var(--orange);background:none;font-size:11px;padding:4px 8px;border:1px solid rgba(245,158,11,0.3);border-radius:6px;cursor:pointer">Demo</button>
|
||||
<a href="/logout" style="color:#ef4444;text-decoration:none;font-size:11px;padding:4px 8px;border:1px solid rgba(239,68,68,0.2);border-radius:6px;opacity:0.7">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
@ -1050,7 +1131,46 @@ async function deleteRun(id) {
|
||||
renderHistoryList();
|
||||
}
|
||||
|
||||
// ─── DEMO MODE ───────────────────────────────
|
||||
async function checkDemo() {
|
||||
try {
|
||||
const r = await fetch('/api/demo/status');
|
||||
const d = await r.json();
|
||||
updateDemoUI(d.active);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function updateDemoUI(active) {
|
||||
const btn = document.getElementById('demo-toggle');
|
||||
const 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 (active) {
|
||||
if (!banner) {
|
||||
const b = document.createElement('div');
|
||||
b.id = 'demo-banner';
|
||||
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:rgba(34,197,94,0.1);border-bottom:1px solid rgba(34,197,94,0.3);color:#22c55e;text-align:center;font-size:11px;padding:4px;z-index:50;font-weight:500';
|
||||
b.textContent = 'DEMO MODE — public access enabled';
|
||||
document.body.prepend(b);
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDemo() {
|
||||
const r = await fetch('/api/demo/toggle', {method:'POST'});
|
||||
const d = await r.json();
|
||||
if (d.error) return;
|
||||
updateDemoUI(d.active);
|
||||
}
|
||||
|
||||
loadModels();
|
||||
checkDemo();
|
||||
</script>
|
||||
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
|
||||
<div class="repipe-modal">
|
||||
@ -1161,6 +1281,7 @@ ADMIN_HTML = r"""
|
||||
<div class="tab" onclick="switchTab('models')">Models</div>
|
||||
<div class="tab" onclick="switchTab('openrouter')">OpenRouter</div>
|
||||
<div class="tab" onclick="switchTab('timeouts')">Timeouts</div>
|
||||
<div class="tab" onclick="switchTab('security')">Security</div>
|
||||
</div>
|
||||
|
||||
<!-- PROVIDERS TAB -->
|
||||
@ -1243,6 +1364,22 @@ ADMIN_HTML = r"""
|
||||
<div id="timeout-list"><div class="empty">Loading models...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<div id="demo-status-admin" style="font-size:13px">Status: <strong style="color:var(--text2)">Off</strong></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>IP Allowlist <button class="btn" style="margin-left:auto" onclick="addAllowlistIP()">+ Add IP</button></h3>
|
||||
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">These IPs are never rate-limited. Your local network (192.168.1.*) is always allowed.</p>
|
||||
<div id="allowlist"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -1456,6 +1593,53 @@ function switchTab(name) {
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-'+name));
|
||||
if (name === 'timeouts') renderTimeouts();
|
||||
if (name === 'models') { loadOllamaModels(); renderCloudModels(); }
|
||||
if (name === 'security') { loadDemoStatus(); loadAllowlist(); }
|
||||
}
|
||||
|
||||
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';
|
||||
btn.className = 'btn btn-g';
|
||||
st.innerHTML = 'Status: <strong style="color:var(--text2)">Off</strong>';
|
||||
}
|
||||
}
|
||||
|
||||
async function adminToggleDemo() {
|
||||
await fetch('/api/demo/toggle', {method:'POST'});
|
||||
loadDemoStatus();
|
||||
toast('Demo mode toggled');
|
||||
}
|
||||
|
||||
async function loadAllowlist() {
|
||||
const r = await fetch('/api/demo/allowlist');
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('allowlist');
|
||||
if (!d.ips || !d.ips.length) { el.innerHTML = '<div class="empty">No IPs in allowlist.</div>'; return; }
|
||||
el.innerHTML = d.ips.map(ip =>
|
||||
`<div class="model-row"><span class="name">${ip}</span>${ip.startsWith('192.168.1.') ? '<span class="meta">LAN</span>' : ''}<button class="btn btn-sm btn-r" onclick="removeAllowIP('${ip}')">Remove</button></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function addAllowlistIP() {
|
||||
const ip = prompt('Enter IP address to allowlist:');
|
||||
if (!ip) return;
|
||||
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'add'})});
|
||||
loadAllowlist();
|
||||
toast('Added ' + ip);
|
||||
}
|
||||
|
||||
async function removeAllowIP(ip) {
|
||||
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'remove'})});
|
||||
loadAllowlist();
|
||||
toast('Removed ' + ip);
|
||||
}
|
||||
|
||||
function toast(msg, ok=true) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user