From 4f5ef164c365bc17fe48500e2c892f560033603e Mon Sep 17 00:00:00 2001
From: kobold-ai
Date: Mon, 21 Sep 2026 10:28:08 +0200
Subject: [PATCH] feat(auth): required login barrier with 1-click demo access
and session management
---
app/auth.py | 26 +++++
app/main.py | 229 ++++++++++++++++++++++++++++++++++-----
app/static/style.css | 180 +++++++++++++++++++++++++++++-
app/templates/base.html | 8 ++
app/templates/index.html | 37 ++++---
app/templates/login.html | 113 +++++++++++++++++++
6 files changed, 547 insertions(+), 46 deletions(-)
create mode 100644 app/auth.py
create mode 100644 app/templates/login.html
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..ab12b35
--- /dev/null
+++ b/app/auth.py
@@ -0,0 +1,26 @@
+import hashlib
+import os
+import secrets
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_hex(16)
+ key = hashlib.pbkdf2_hmac(
+ 'sha256',
+ password.encode('utf-8'),
+ salt.encode('utf-8'),
+ 100000
+ )
+ return f"{salt}${key.hex()}"
+
+def verify_password(password: str, hashed: str) -> bool:
+ try:
+ salt, key_hex = hashed.split('$')
+ computed_key = hashlib.pbkdf2_hmac(
+ 'sha256',
+ password.encode('utf-8'),
+ salt.encode('utf-8'),
+ 100000
+ )
+ return secrets.compare_digest(computed_key.hex(), key_hex)
+ except Exception:
+ return False
diff --git a/app/main.py b/app/main.py
index 2bdb0d4..5921fd6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,29 +1,36 @@
import os
from datetime import datetime
-from typing import Optional
+from typing import Optional, Dict
from decimal import Decimal
from fastapi import FastAPI, Request, Depends, HTTPException, Form, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy.orm import Session
from .database import get_db
from .models import Tenant, User, Product, Event, Order, OrderItem
+from .auth import hash_password, verify_password
app = FastAPI(title="Schmankerl – Das moderne Brotzeitportal")
+# Session Middleware für sichere Login-Cookies
+app.add_middleware(
+ SessionMiddleware,
+ secret_key=os.getenv("SECRET_KEY", "schmankerl-secret-key-waiswuaschd-2026!"),
+ session_cookie="schmankerl_session",
+ max_age=86400 * 30 # 30 Tage
+)
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]:
- """
- Ermittelt den Mandanten anhand des Host-Headers (Subdomain)
- z.B. demo.dev.waiswuaschd.de -> slug='demo'
- """
+ """Ermittelt den Mandanten anhand der Subdomain (Host-Header)."""
host = request.headers.get("host", "").split(":")[0].lower()
parts = host.split(".")
@@ -41,12 +48,55 @@ def resolve_tenant(request: Request, db: Session) -> Optional[Tenant]:
return tenant
+def get_current_user(request: Request, db: Session) -> Optional[User]:
+ """Liest den eingeloggten Benutzer aus der Session."""
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return None
+ return db.query(User).filter(User.id == user_id, User.is_active == True).first()
+
+
+def seed_demo_users_if_needed(tenant: Tenant, db: Session):
+ """Stellt sicher, dass die Standard-Accounts für die Demo existieren."""
+ demo_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "maxi@waiswuaschd.de").first()
+ if not demo_user:
+ demo_user = User(
+ tenant_id=tenant.id,
+ email="maxi@waiswuaschd.de",
+ username="maxi",
+ first_name="Maxi",
+ last_name="Mustermann",
+ password_hash=hash_password("demo123"),
+ role="user"
+ )
+ db.add(demo_user)
+
+ admin_user = db.query(User).filter(User.tenant_id == tenant.id, User.email == "admin@waiswuaschd.de").first()
+ if not admin_user:
+ admin_user = User(
+ tenant_id=tenant.id,
+ email="admin@waiswuaschd.de",
+ username="admin",
+ first_name="Franz",
+ last_name="Xaver",
+ password_hash=hash_password("admin123"),
+ role="admin"
+ )
+ db.add(admin_user)
+ db.commit()
+
+
@app.get("/", response_class=HTMLResponse)
def index(request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
+ current_user = get_current_user(request, db)
+ if not current_user:
+ # Require Login: Weiterleitung zur Anmeldeseite
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
# Aktuelle offene Runde suchen
event = db.query(Event).filter(
Event.tenant_id == tenant.id,
@@ -59,33 +109,154 @@ def index(request: Request, db: Session = Depends(get_db)):
Product.is_active == True
).order_by(Product.sort_order.asc(), Product.id.asc()).all()
- # Bestehende Bestellungen für die Event-Runde zählen
+ # Bestehende Bestellung des Users in diesem Event
+ existing_order = None
+ user_items: Dict[int, int] = {}
+ if event:
+ existing_order = db.query(Order).filter(
+ Order.tenant_id == tenant.id,
+ Order.event_id == event.id,
+ Order.user_id == current_user.id
+ ).first()
+ if existing_order:
+ for it in existing_order.items:
+ user_items[it.product_id] = it.quantity
+
+ # Bestellungen insgesamt zählen
order_count = 0
if event:
order_count = db.query(Order).filter(Order.event_id == event.id).count()
return templates.TemplateResponse(request, "index.html", context={
"tenant": tenant,
+ "current_user": current_user,
"event": event,
"products": products,
+ "existing_order": existing_order,
+ "user_items": user_items,
"order_count": order_count,
"now": datetime.now()
})
-@app.post("/order")
-async def place_order(
+@app.get("/login", response_class=HTMLResponse)
+def login_page(request: Request, db: Session = Depends(get_db), error: Optional[str] = None, success: Optional[str] = None):
+ tenant = resolve_tenant(request, db)
+ if tenant and tenant.slug == "demo":
+ seed_demo_users_if_needed(tenant, db)
+
+ current_user = get_current_user(request, db)
+ if current_user:
+ return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+
+ return templates.TemplateResponse(request, "login.html", context={
+ "tenant": tenant,
+ "error": error,
+ "success": success,
+ "current_user": None
+ })
+
+
+@app.post("/login")
+def login(
request: Request,
- first_name: str = Form(...),
- last_name: str = Form(...),
email: str = Form(...),
- event_id: int = Form(...),
+ password: str = Form(...),
+ db: Session = Depends(get_db)
+):
+ tenant = resolve_tenant(request, db)
+ clean_email = email.strip().lower()
+
+ user = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
+ if not user or not verify_password(password, user.password_hash):
+ return templates.TemplateResponse(request, "login.html", context={
+ "tenant": tenant,
+ "error": "Ungültige E-Mail-Adresse oder falsches Passwort.",
+ "current_user": None
+ }, status_code=400)
+
+ # Session setzen
+ request.session["user_id"] = user.id
+ return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@app.post("/login/quick")
+def quick_demo_login(
+ request: Request,
+ role: str = Form(...),
db: Session = Depends(get_db)
):
tenant = resolve_tenant(request, db)
if not tenant:
raise HTTPException(status_code=404, detail="Mandant nicht gefunden")
+ seed_demo_users_if_needed(tenant, db)
+
+ target_email = "admin@waiswuaschd.de" if role == "admin" else "maxi@waiswuaschd.de"
+ user = db.query(User).filter(User.tenant_id == tenant.id, User.email == target_email).first()
+ if not user:
+ raise HTTPException(status_code=500, detail="Demo-Benutzer konnte nicht geladen werden")
+
+ request.session["user_id"] = user.id
+ if role == "admin":
+ return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
+ return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@app.post("/register")
+def register(
+ request: Request,
+ first_name: str = Form(...),
+ last_name: str = Form(...),
+ email: str = Form(...),
+ password: str = Form(...),
+ db: Session = Depends(get_db)
+):
+ tenant = resolve_tenant(request, db)
+ clean_email = email.strip().lower()
+
+ existing = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
+ if existing:
+ return templates.TemplateResponse(request, "login.html", context={
+ "tenant": tenant,
+ "error": "Diese E-Mail-Adresse ist bereits registriert. Bitte melde dich an.",
+ "current_user": None
+ }, status_code=400)
+
+ username = clean_email.split("@")[0]
+ new_user = User(
+ tenant_id=tenant.id,
+ email=clean_email,
+ username=username,
+ first_name=first_name.strip(),
+ last_name=last_name.strip(),
+ password_hash=hash_password(password),
+ role="user"
+ )
+ db.add(new_user)
+ db.commit()
+
+ request.session["user_id"] = new_user.id
+ return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@app.get("/logout")
+def logout(request: Request):
+ request.session.clear()
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
+
+@app.post("/order")
+async def place_order(
+ request: Request,
+ event_id: int = Form(...),
+ db: Session = Depends(get_db)
+):
+ tenant = resolve_tenant(request, db)
+ current_user = get_current_user(request, db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first()
if not event or event.status != "open":
raise HTTPException(status_code=400, detail="Diese Bestellrunde ist bereits geschlossen")
@@ -93,23 +264,7 @@ async def place_order(
if event.deadline < datetime.now():
raise HTTPException(status_code=400, detail="Die Bestellfrist ist leider abgelaufen!")
- # User ermitteln oder anlegen
- clean_email = email.strip().lower()
- user = db.query(User).filter(User.tenant_id == tenant.id, User.email == clean_email).first()
- if not user:
- username = clean_email.split("@")[0]
- user = User(
- tenant_id=tenant.id,
- email=clean_email,
- username=username,
- first_name=first_name.strip(),
- last_name=last_name.strip(),
- role="user"
- )
- db.add(user)
- db.flush()
-
- # Formulardaten auslesen (Mengen je Produkt)
+ # Formulardaten auslesen
form_data = await request.form()
order_items = []
total_price = Decimal("0.00")
@@ -138,7 +293,7 @@ async def place_order(
existing_order = db.query(Order).filter(
Order.tenant_id == tenant.id,
Order.event_id == event.id,
- Order.user_id == user.id
+ Order.user_id == current_user.id
).first()
if existing_order:
@@ -153,7 +308,7 @@ async def place_order(
new_order = Order(
tenant_id=tenant.id,
event_id=event.id,
- user_id=user.id,
+ user_id=current_user.id,
total_price=total_price,
is_paid=False
)
@@ -171,12 +326,17 @@ async def place_order(
@app.get("/order/{order_id}/success", response_class=HTMLResponse)
def order_success(order_id: int, request: Request, db: Session = Depends(get_db)):
tenant = resolve_tenant(request, db)
+ current_user = get_current_user(request, db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Bestellung nicht gefunden")
return templates.TemplateResponse(request, "order_success.html", context={
"tenant": tenant,
+ "current_user": current_user,
"order": order
})
@@ -187,6 +347,10 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
if not tenant:
return templates.TemplateResponse(request, "not_found_tenant.html", context={})
+ current_user = get_current_user(request, db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
# Neuestes Event finden
event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first()
@@ -211,6 +375,7 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse(request, "admin.html", context={
"tenant": tenant,
+ "current_user": current_user,
"event": event,
"orders": orders,
"item_sums": item_sums,
@@ -221,6 +386,10 @@ def admin_dashboard(request: Request, db: Session = Depends(get_db)):
@app.post("/admin/toggle-paid/{order_id}")
def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)):
+ current_user = get_current_user(request, db)
+ if not current_user:
+ return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
+
order = db.query(Order).filter(Order.id == order_id).first()
if order:
order.is_paid = not order.is_paid
diff --git a/app/static/style.css b/app/static/style.css
index dec8137..f8152d5 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -40,7 +40,7 @@ body {
}
.container.narrow {
- max-width: 600px;
+ max-width: 580px;
}
/* Navbar */
@@ -87,7 +87,7 @@ body {
.nav-links {
display: flex;
align-items: center;
- gap: 20px;
+ gap: 18px;
}
.nav-links a {
@@ -102,6 +102,20 @@ body {
color: var(--text-main);
}
+.user-greeting {
+ color: var(--text-main);
+ font-size: 0.85rem;
+ font-weight: 600;
+ background: rgba(255, 255, 255, 0.05);
+ padding: 4px 10px;
+ border-radius: 6px;
+}
+
+.nav-logout {
+ color: #ef4444 !important;
+ font-size: 0.85rem !important;
+}
+
.nav-links a.nav-admin {
background: rgba(251, 191, 36, 0.12);
color: var(--accent-gold);
@@ -267,6 +281,31 @@ body {
backdrop-filter: blur(12px);
}
+.logged-in-badge {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: rgba(11, 15, 25, 0.8);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 12px 16px;
+ margin-bottom: 16px;
+}
+
+.user-avatar {
+ font-size: 1.5rem;
+}
+
+.notice-box {
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: var(--radius-md);
+ padding: 12px 14px;
+ color: var(--text-main);
+ font-size: 0.85rem;
+ margin-bottom: 16px;
+}
+
.input-group {
margin-bottom: 16px;
}
@@ -344,6 +383,143 @@ body {
margin-top: 12px;
}
+/* Login Page */
+.login-box {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 36px;
+ backdrop-filter: blur(12px);
+}
+
+.login-header {
+ text-align: center;
+ margin-bottom: 24px;
+}
+
+.login-icon {
+ font-size: 2.5rem;
+ margin-bottom: 12px;
+}
+
+.login-header h1 {
+ font-size: 1.6rem;
+ margin-bottom: 6px;
+}
+
+.login-sub {
+ color: var(--text-muted);
+ font-size: 0.95rem;
+ margin-bottom: 14px;
+}
+
+.privacy-note {
+ background: rgba(56, 189, 248, 0.08);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+.demo-login-card {
+ background: rgba(11, 15, 25, 0.9);
+ border: 1px solid rgba(251, 191, 36, 0.3);
+ border-radius: var(--radius-md);
+ padding: 18px;
+ margin-bottom: 28px;
+}
+
+.demo-login-card h3 {
+ font-size: 1rem;
+ color: var(--accent-gold);
+ margin-bottom: 4px;
+}
+
+.demo-btn-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.btn-demo {
+ width: 100%;
+ padding: 12px 10px;
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.3);
+ border-radius: var(--radius-md);
+ color: var(--text-main);
+ cursor: pointer;
+ text-align: center;
+ transition: all 0.2s;
+}
+
+.btn-demo:hover {
+ background: rgba(56, 189, 248, 0.2);
+ border-color: var(--accent);
+}
+
+.btn-demo.admin {
+ background: rgba(251, 191, 36, 0.1);
+ border-color: rgba(251, 191, 36, 0.3);
+}
+
+.btn-demo.admin:hover {
+ background: rgba(251, 191, 36, 0.2);
+ border-color: var(--accent-gold);
+}
+
+.btn-demo small {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+
+.auth-tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 20px;
+}
+
+.auth-tab {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ font-size: 0.95rem;
+ font-weight: 600;
+ padding: 12px;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+}
+
+.auth-tab.active {
+ color: var(--text-main);
+ border-bottom-color: var(--accent);
+}
+
+.alert-danger {
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid var(--danger);
+ color: #fca5a5;
+ padding: 10px 14px;
+ border-radius: var(--radius-md);
+ font-size: 0.85rem;
+ margin-bottom: 18px;
+}
+
+.alert-success {
+ background: rgba(34, 197, 94, 0.15);
+ border: 1px solid var(--success);
+ color: #86efac;
+ padding: 10px 14px;
+ border-radius: var(--radius-md);
+ font-size: 0.85rem;
+ margin-bottom: 18px;
+}
+
/* Success Card */
.success-card {
background: var(--bg-card);
diff --git a/app/templates/base.html b/app/templates/base.html
index 5d605b3..6baed85 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -15,8 +15,16 @@
🥨 Schmankerl {{ tenant.name if tenant else 'Portal' }}
+ {% if current_user %}
Brotzeit bestellen
+ {% if current_user.role == 'admin' %}
👨🍳 Einkäufer-Cockpit
+ {% endif %}
+ 👤 {{ current_user.first_name }}
+ Abmelden
+ {% else %}
+ Anmelden
+ {% endif %}
Zur Plattform
diff --git a/app/templates/index.html b/app/templates/index.html
index ccc8e43..7e17a31 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -13,7 +13,7 @@
Bestellschluss: {{ event.deadline.strftime('%d.%m.%Y um %H:%M Uhr') }}
- 👥 Bereits {{ order_count }} Bestellungen eingegangen
+ 👥 Bereits {{ order_count }} Bestellungen von Kollegen eingegangen
@@ -27,6 +27,7 @@
1. Brotzeit auswählen
{% for product in products %}
+ {% set current_qty = user_items.get(product.id, 0) %}
@@ -46,19 +47,21 @@
@@ -81,7 +88,9 @@
🏖️
Aktuell keine offene Bestellrunde
Für dieses Team ist im Moment keine Brotzeit geplant.
+ {% if current_user.role == 'admin' %}
Neue Bestellrunde im Cockpit anlegen
+ {% endif %}
{% endif %}
diff --git a/app/templates/login.html b/app/templates/login.html
new file mode 100644
index 0000000..b7e9078
--- /dev/null
+++ b/app/templates/login.html
@@ -0,0 +1,113 @@
+{% extends "base.html" %}
+
+{% block title %}Anmelden – Schmankerl{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+ {% if success %}
+
{{ success }}
+ {% endif %}
+
+
+
+
🚀 1-Klick Demo-Zugang
+
Wähle eine Rolle zum sofortigen Ausprobieren:
+
+
+
+
+
+
+
+
+ Anmelden
+ Neu registrieren
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}