Owner accounts: auth, password reset, CRM dashboard visibility (#19)

Flask-session login scoped to one client_id (never a request param),
self-service + operator-triggered password reset via single-use tokens,
and an Owner accounts tab on the CRM dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 17:00:37 +02:00
parent 644c99ee30
commit 59c35ce39f
14 changed files with 635 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Owner login, password reset, and the session helper every owner-scoped
route (tickets 6/7/8) will resolve client_id through (#19).
Flask's built-in signed-cookie session is "scoped to exactly one client_id":
session["client_id"] is set once at login, and login_required is the only
sanctioned way a later owner route may read it back -- never from a request
param, or a client could simply pass another tenant's client_id.
"""
import functools
from flask import Blueprint, redirect, render_template, request, session, url_for
import booking_db as bdb
import owner_mail
bp = Blueprint("owner_auth", __name__, url_prefix="/owner")
def login_required(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
if not session.get("client_id") or not session.get("user_id"):
return redirect(url_for("owner_auth.login"))
return view(*args, **kwargs)
return wrapped
@bp.get("/login")
def login():
return render_template("owner/login.html", error=None)
@bp.post("/login")
def login_submit():
email = (request.form.get("email") or "").strip().lower()
password = request.form.get("password") or ""
user = bdb.find_user_by_email(email)
if user is None or not bdb.verify_password(user, password):
return render_template(
"owner/login.html", error="E-Mail oder Passwort ist falsch."), 401
session.clear()
session["user_id"] = user["user_id"]
session["client_id"] = user["client_id"]
return redirect(url_for("owner_auth.dashboard"))
@bp.get("/logout")
def logout():
session.clear()
return redirect(url_for("owner_auth.login"))
@bp.get("/")
@login_required
def dashboard():
# Tickets 6/7/8 (calendar, manual booking, settings) build the real
# dashboard content on top of this session; this is just the landing
# page proving a login resolved to (and is scoped to) one client_id.
client = bdb.get_client(session["client_id"])
return render_template("owner/dashboard.html", client=client)
@bp.get("/forgot-password")
def forgot_password():
return render_template("owner/forgot_password.html", sent=False)
@bp.post("/forgot-password")
def forgot_password_submit():
email = (request.form.get("email") or "").strip().lower()
user = bdb.find_user_by_email(email)
if user is not None:
client = bdb.get_client(user["client_id"])
tok = bdb.create_password_reset_token(user["user_id"])
owner_mail.send_password_reset(client, user, tok["token"])
# Same response whether or not the email matched a user -- confirming or
# denying an email's existence to an anonymous requester is an
# account-enumeration leak.
return render_template("owner/forgot_password.html", sent=True)
@bp.get("/reset-password/<token>")
def reset_password(token):
return render_template(
"owner/reset_password.html", token=token, error=None, done=False)
@bp.post("/reset-password/<token>")
def reset_password_submit(token):
password = request.form.get("password") or ""
if len(password) < 8:
return render_template(
"owner/reset_password.html", token=token, done=False,
error="Passwort muss mindestens 8 Zeichen haben."), 400
user_id = bdb.consume_password_reset_token(token, password)
if user_id is None:
return render_template(
"owner/reset_password.html", token=token, done=False,
error="Dieser Link ist ungültig oder abgelaufen."), 400
return render_template(
"owner/reset_password.html", token=token, error=None, done=True)