Add password protection to all admin routes

All /admin/* routes now require login via /admin/login. The password
is set via the ADMIN_PASS environment variable on the server
(default: hiacts2026 — change this in PythonAnywhere env vars before
the conference). /admin/logout clears the session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 4.6 committed 2026-06-07 17:10:41 +02:00
1 parent a4e36586d0
commit 7e83c4f2c0
1 file changed
+59 -9
+59 -9
View File
@@ -285,14 +285,23 @@ _session_version = [1] # incremented on reset to invalidate all existing sessio
# MAIL_PASS — your email password or app password
# =============================================================================
MAIL_HOST = os.environ.get('MAIL_HOST', '')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USER = os.environ.get('MAIL_USER', '')
MAIL_PASS = os.environ.get('MAIL_PASS', '')
MAIL_HOST = os.environ.get('MAIL_HOST', '')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USER = os.environ.get('MAIL_USER', '')
MAIL_PASS = os.environ.get('MAIL_PASS', '')
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5001')
ADMIN_PASS = os.environ.get('ADMIN_PASS', 'hiacts2026') # set this in PythonAnywhere env vars
# Set this to your public URL so keep-links in emails work correctly.
# On PythonAnywhere: Web tab -> Environment variables -> BASE_URL = https://yourusername.pythonanywhere.com
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5001')
def admin_required(f):
"""Decorator that redirects to /admin/login if the session is not authenticated."""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('admin_ok'):
return redirect('/admin/login')
return f(*args, **kwargs)
return decorated
def get_respondents(snapshot):
@@ -1246,7 +1255,45 @@ To change your mind at any time, email <a href="mailto:mblack@txproducts.de">mbl
# Sends the full PDF analysis report to every collected email address (run this after the conference)
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
error = ''
if request.method == 'POST':
if request.form.get('password') == ADMIN_PASS:
session['admin_ok'] = True
return redirect('/admin')
error = 'Incorrect password.'
return f'''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Admin Login</title>
<style>
body{{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8}}
.box{{background:white;border-radius:16px;padding:40px 48px;max-width:360px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.12)}}
h2{{color:#1a365d;margin:0 0 6px;font-size:20px}}
p{{color:#8a9baf;font-size:13px;margin:0 0 24px}}
input{{width:100%;padding:12px 14px;border:1.5px solid #dde5ee;border-radius:9px;font-size:15px;font-family:inherit;box-sizing:border-box;margin-bottom:12px;outline:none}}
input:focus{{border-color:#2563eb}}
button{{width:100%;padding:13px;background:linear-gradient(135deg,#0f2744,#1a4a8a);color:white;border:none;border-radius:9px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit}}
.err{{color:#dc2626;font-size:13px;margin-bottom:10px}}
</style></head>
<body><div class="box">
<h2>Admin Access</h2>
<p>Hi-Acts Innovation Challenge Arena</p>
<form method="POST">
{"<div class='err'>" + error + "</div>" if error else ""}
<input type="password" name="password" placeholder="Password" autofocus>
<button type="submit">Enter</button>
</form>
</div></body></html>'''
@app.route('/admin/logout')
def admin_logout():
session.pop('admin_ok', None)
return redirect('/admin/login')
@app.route('/admin/send_all_reports', methods=['POST'])
@admin_required
def send_all_reports():
emails = load_emails()
if not emails:
@@ -1259,6 +1306,7 @@ def send_all_reports():
# Sends a deletion confirmation to every stored email, then wipes emails.json
@app.route('/admin/delete_and_notify', methods=['POST'])
@admin_required
def delete_and_notify():
records = load_email_records()
to_delete = [r for r in records if not r.get('retained', False)]
@@ -1283,6 +1331,7 @@ def delete_and_notify():
# Downloads all poll responses as a CSV file — one row per respondent
@app.route('/admin/export')
@admin_required
def export_csv():
n = max((len(answers[q['id']]) for q in QUESTIONS), default=0)
output = io.StringIO()
@@ -1356,9 +1405,8 @@ def demo_status():
# Admin page — shows all raw responses per question with bar charts
# WARNING: this page has no password protection.
# The web dev team should restrict access to this URL in production.
@app.route('/admin')
@admin_required
def admin():
processed = []
for q in QUESTIONS:
@@ -1377,6 +1425,7 @@ def admin():
# Clears all answers — used by the admin page reset button
@app.route('/admin/reset', methods=['POST'])
@admin_required
def reset():
answers.clear()
save_answers()
@@ -1399,6 +1448,7 @@ def seed():
# Generates the analysis PDF on demand and returns it as a download
@app.route('/admin/report')
@admin_required
def report():
pdf_bytes = generate_analysis_pdf()
if not pdf_bytes: