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' }} 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') }}

@@ -27,6 +27,7 @@

1. Brotzeit auswählen

{% for product in products %} + {% set current_qty = user_items.get(product.id, 0) %}

{{ product.name }}

@@ -35,7 +36,7 @@
- +
@@ -46,19 +47,21 @@
-

2. Deine Angaben

-
- - +

2. Zusammenfassung

+ +
+
👤
+
+ {{ current_user.first_name }} {{ current_user.last_name }}
+ {{ current_user.email }} +
-
- - -
-
- - + + {% if existing_order %} +
+ ℹ️ Du hast bereits für diese Runde bestellt. Du kannst deine Mengen hier anpassen und aktualisieren.
+ {% endif %}
@@ -68,9 +71,13 @@
- Du kannst deine Bestellung bis zum Stichtag jederzeit mit derselben E-Mail-Adresse ändern. + Du kannst deine Bestellung bis zum Stichtag jederzeit hier im Portal anpassen.
@@ -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 %} + + + + + +
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+ +
+ + +{% endblock %}