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
+41
View File
@@ -17,16 +17,26 @@ from decimal import Decimal
from flask import Flask, jsonify, request, Response
from waitress import serve
import booking_db as bdb
import db
import owner_mail
from sheets import Sheets
from booking_api import bp as booking_bp
from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_booking_bp
from owner_auth import bp as owner_auth_bp
app = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp)
app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_booking_bp)
app.register_blueprint(owner_auth_bp)
# Dedicated secret for the owner-login session cookie -- deliberately not
# shared with CRM_API_TOKEN or BOOKING_TOKEN_SECRET (#19), same reasoning as
# booking_api.py's own token secret: rotating one must never silently affect
# the others.
app.secret_key = os.environ.get("SESSION_SECRET_KEY", "")
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Separate read-only token for the public iCal feed (calendar apps can't send
@@ -272,6 +282,37 @@ def delete_entity(entity, ident):
return jsonify({"deleted": ident})
@app.get("/api/owner_users")
def list_owner_users():
"""Owner-accounts list for the CRM dashboard's new tab (#19). Requires
the CRM token to read, same as credentials -- these rows identify who can
log into a client's owner portal."""
if not authed():
return jsonify({"error": "forbidden"}), 403
rows = bdb.list_users()
return jsonify({"entity": "owner_users", "count": len(rows), "rows": rows_json(rows)})
@app.post("/api/owner_users/<user_id>/reset-password")
def trigger_owner_password_reset(user_id):
"""Operator-triggered reset (#19): sends the same reset email an owner
would send themselves via /owner/forgot-password."""
if not authed():
return jsonify({"error": "forbidden"}), 403
user = bdb.get_user(user_id)
if user is None:
return jsonify({"error": "not found"}), 404
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"])
with db.connect() as conn, conn.cursor() as cur:
log_activity(cur, user["client_id"], "owner password reset",
f"user_id={user_id} (operator-triggered)")
conn.commit()
mirror_async("activity_log")
return jsonify({"sent": user_id})
@app.post("/api/sync")
def sync_all():
"""Full DB -> Sheets resync of every tab (manual / alignment)."""