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
+3
View File
@@ -2,6 +2,9 @@
DB_PASSWORD=change-me-strong DB_PASSWORD=change-me-strong
CRM_API_TOKEN=change-me-long-random CRM_API_TOKEN=change-me-long-random
BOOKING_TOKEN_SECRET=change-me-long-random-too BOOKING_TOKEN_SECRET=change-me-long-random-too
# Owner-login session cookie signing key (#19). Dedicated secret -- rotating
# it just logs owners out, without touching CRM_API_TOKEN/BOOKING_TOKEN_SECRET.
SESSION_SECRET_KEY=change-me-long-random-session-too
SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8 SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8
# Booking confirmation email (#18). Left blank, sending is skipped (logged, # Booking confirmation email (#18). Left blank, sending is skipped (logged,
# not fatal) -- mail relay setup is a separate infra/triage item. # not fatal) -- mail relay setup is a separate infra/triage item.
+41
View File
@@ -17,16 +17,26 @@ from decimal import Decimal
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response
from waitress import serve from waitress import serve
import booking_db as bdb
import db import db
import owner_mail
from sheets import Sheets from sheets import Sheets
from booking_api import bp as booking_bp from booking_api import bp as booking_bp
from public_booking import bp as public_booking_bp from public_booking import bp as public_booking_bp
from manage_booking import bp as manage_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 = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp) app.register_blueprint(booking_bp)
app.register_blueprint(public_booking_bp) app.register_blueprint(public_booking_bp)
app.register_blueprint(manage_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", "") CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Separate read-only token for the public iCal feed (calendar apps can't send # 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}) 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") @app.post("/api/sync")
def sync_all(): def sync_all():
"""Full DB -> Sheets resync of every tab (manual / alignment).""" """Full DB -> Sheets resync of every tab (manual / alignment)."""
+31
View File
@@ -273,6 +273,37 @@ def get_user_by_email(client_id, email):
return cur.fetchone() return cur.fetchone()
def find_user_by_email(email):
"""Login lookup (#19): users.email is globally unique, and at login time
there's no client_id yet to scope by -- resolving client_id is exactly
what a successful login establishes for the session."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
return cur.fetchone()
def get_user(user_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM users WHERE user_id = %s", (user_id,))
return cur.fetchone()
def list_users(client_id=None):
"""Owner accounts for the CRM operator dashboard (#19). Unlike the rest
of this module, this one is deliberately allowed to span every tenant
when client_id is omitted -- an operator manages all clients, not one."""
with db.connect() as conn, conn.cursor() as cur:
if client_id is None:
cur.execute(
"SELECT user_id, client_id, email, created_at FROM users "
"ORDER BY client_id, email")
else:
cur.execute(
"SELECT user_id, client_id, email, created_at FROM users "
"WHERE client_id = %s ORDER BY email", (client_id,))
return cur.fetchall()
def verify_password(user, password): def verify_password(user, password):
return check_password_hash(user["password_hash"], password) return check_password_hash(user["password_hash"], password)
+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)
+32
View File
@@ -0,0 +1,32 @@
"""Owner password-reset email (#19), following the same
sender-resolution/template/mailer.send_email shape as booking_mail.py's
booking confirmation -- see that module for why this never talks to smtplib
directly.
"""
import os
from flask import render_template
import booking_mail
import mailer
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://onboard.mivanchenko.de").rstrip("/")
def reset_url(token):
return f"{PUBLIC_BASE_URL}/owner/reset-password/{token}"
def send_password_reset(client, user, token):
business_name = (client or {}).get("business_name") or "Ihr Konto"
html = render_template(
"emails/password_reset.html",
business_name=business_name,
reset_url=reset_url(token),
)
mailer.send_email(
user["email"],
f"Passwort zurücksetzen {business_name}",
html,
from_addr=booking_mail._sender_for(client),
)
+28
View File
@@ -82,6 +82,7 @@
<button class="tab active" data-e="leads">Leads<span class="n" id="n-leads"></span></button> <button class="tab active" data-e="leads">Leads<span class="n" id="n-leads"></span></button>
<button class="tab" data-e="clients">Clients<span class="n" id="n-clients"></span></button> <button class="tab" data-e="clients">Clients<span class="n" id="n-clients"></span></button>
<button class="tab" data-e="credentials">Credentials<span class="n" id="n-credentials"></span></button> <button class="tab" data-e="credentials">Credentials<span class="n" id="n-credentials"></span></button>
<button class="tab" data-e="owner_users">Eigentümer-Konten<span class="n" id="n-owner_users"></span></button>
</div> </div>
<div class="bar"> <div class="bar">
<input id="search" placeholder="Filtern …" /> <input id="search" placeholder="Filtern …" />
@@ -144,6 +145,7 @@
} }
function render(){ function render(){
if(current === 'owner_users'){ renderOwnerUsers(); return; }
const cols = COLS[current]; const cols = COLS[current];
let rows = cache[current] || []; let rows = cache[current] || [];
const q = document.getElementById('search').value.trim().toLowerCase(); const q = document.getElementById('search').value.trim().toLowerCase();
@@ -170,6 +172,31 @@
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`; document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
} }
function renderOwnerUsers(){
let rows = cache.owner_users || [];
const q = document.getElementById('search').value.trim().toLowerCase();
if(q) rows = rows.filter(r => ['client_id','email'].some(c => String(r[c]??'').toLowerCase().includes(q)));
if(!rows.length){ document.getElementById('table').innerHTML = '<div class="empty">Keine Einträge.</div>'; return; }
const head = '<tr><th>client_id</th><th>email</th><th>created_at</th><th></th></tr>';
const body = rows.map(r => {
const created = r.created_at ? String(r.created_at).replace('T',' ').slice(0,16) : '';
return `<tr><td title="${esc(r.client_id)}">${esc(r.client_id)}</td><td>${esc(r.email)}</td>` +
`<td>${esc(created)}</td><td class="actcol">` +
`<button class="act" onclick="sendOwnerReset('${esc(r.user_id)}')">Reset-Link senden</button></td></tr>`;
}).join('');
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
}
async function sendOwnerReset(userId){
clearErr();
try {
const r = await fetch(`${API}/owner_users/${encodeURIComponent(userId)}/reset-password`, {
method: 'POST', headers: { 'X-CRM-Token': TOKEN } });
if(!r.ok) throw new Error('HTTP '+r.status);
showOk('✓ Reset-Link gesendet');
} catch(e){ showErr('Senden fehlgeschlagen: '+e.message); }
}
function toggleSecret(id){ function toggleSecret(id){
if(revealed.has(id)) revealed.delete(id); else revealed.add(id); if(revealed.has(id)) revealed.delete(id); else revealed.add(id);
render(); render();
@@ -276,6 +303,7 @@
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active')); document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active'); t.classList.add('active');
current = t.dataset.e; current = t.dataset.e;
document.getElementById('add').style.display = (current === 'owner_users') ? 'none' : '';
if(cache[current]) render(); else load(current); if(cache[current]) render(); else load(current);
}); });
document.getElementById('search').oninput = render; document.getElementById('search').oninput = render;
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Passwort zurücksetzen</title>
</head>
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
<p>Sie haben ein neues Passwort für Ihr Konto angefordert (oder es wurde für Sie angefordert).</p>
<p>Über den folgenden Link können Sie ein neues Passwort vergeben:</p>
<p><a href="{{ reset_url }}">{{ reset_url }}</a></p>
<p>Falls Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren.</p>
</body>
</html>
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Mein Konto — {{ client.business_name if client else '' }}</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 480px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px; background: #f6faf9; }
.muted { color: var(--muted); font-size: .85rem; }
a { color: var(--brand); }
</style>
</head>
<body>
<h1>{{ client.business_name if client else 'Mein Konto' }}</h1>
<div class="card">
<p>Sie sind angemeldet.</p>
<p class="muted">Kalender, Buchungen und Einstellungen folgen hier.</p>
</div>
<p class="muted"><a href="{{ url_for('owner_auth.logout') }}">Abmelden</a></p>
</body>
</html>
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Passwort vergessen</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 380px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
input[type=email] { width: 100%; padding: 9px 12px; border: 1px solid var(--line);
border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer;
width: 100%; }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px; background: #f6faf9; }
.muted { color: var(--muted); font-size: .85rem; }
</style>
</head>
<body>
<h1>Passwort vergessen</h1>
{% if sent %}
<div class="card">
<p>Wenn zu dieser E-Mail-Adresse ein Konto existiert, wurde ein Link zum Zurücksetzen
des Passworts versendet.</p>
<p class="muted">Bitte prüfen Sie Ihren Posteingang.</p>
</div>
{% else %}
<form method="post">
<div class="field">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required autocomplete="username" />
</div>
<button class="btn" type="submit">Link anfordern</button>
</form>
{% endif %}
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Anmelden</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 380px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
input[type=email], input[type=password] {
width: 100%; padding: 9px 12px; border: 1px solid var(--line);
border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer;
width: 100%; }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; }
.muted { color: var(--muted); font-size: .85rem; margin-top: 14px; }
a { color: var(--brand); }
</style>
</head>
<body>
<h1>Anmelden</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post">
<div class="field">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required autocomplete="username" />
</div>
<div class="field">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password" />
</div>
<button class="btn" type="submit">Anmelden</button>
</form>
<p class="muted"><a href="{{ url_for('owner_auth.forgot_password') }}">Passwort vergessen?</a></p>
</body>
</html>
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<title>Passwort zurücksetzen</title>
<style>
:root {
--brand: #0f8a7e;
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; max-width: 380px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
input[type=password] { width: 100%; padding: 9px 12px; border: 1px solid var(--line);
border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer;
width: 100%; }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; }
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px; background: #f6faf9; }
.muted { color: var(--muted); font-size: .85rem; }
a { color: var(--brand); }
</style>
</head>
<body>
<h1>Passwort zurücksetzen</h1>
{% if done %}
<div class="card">
<p>Ihr Passwort wurde geändert.</p>
<p class="muted"><a href="{{ url_for('owner_auth.login') }}">Jetzt anmelden</a></p>
</div>
{% else %}
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post">
<div class="field">
<label for="password">Neues Passwort</label>
<input type="password" id="password" name="password" required minlength="8"
autocomplete="new-password" />
</div>
<button class="btn" type="submit">Passwort speichern</button>
</form>
{% endif %}
</body>
</html>
+27
View File
@@ -234,3 +234,30 @@ def test_consume_expired_password_reset_token_fails():
def test_consume_unknown_token_fails(): def test_consume_unknown_token_fails():
assert bdb.consume_password_reset_token("not-a-real-token", "new-password") is None assert bdb.consume_password_reset_token("not-a-real-token", "new-password") is None
# ---- login lookup / operator listing (#19) ----
def test_find_user_by_email_is_not_tenant_scoped():
"""Login happens before client_id is known -- find_user_by_email looks up
by the globally-unique email alone, unlike get_user_by_email."""
u = bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
found = bdb.find_user_by_email("owner@example.com")
assert found["user_id"] == u["user_id"]
assert bdb.find_user_by_email("nobody@example.com") is None
def test_get_user_by_id():
u = bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
assert bdb.get_user(u["user_id"])["email"] == "owner@example.com"
assert bdb.get_user("U-does-not-exist") is None
def test_list_users_scoped_and_unscoped():
a = bdb.create_user(CLIENT_A, "a@example.com", "pw12345")
bdb.create_user(CLIENT_B, "b@example.com", "pw12345")
scoped = bdb.list_users(CLIENT_A)
assert [r["user_id"] for r in scoped] == [a["user_id"]]
everyone = bdb.list_users()
assert {r["client_id"] for r in everyone} == {CLIENT_A, CLIENT_B}
assert "password_hash" not in everyone[0]
+182
View File
@@ -0,0 +1,182 @@
"""Flask test client / real-DB integration tests for owner login, password
reset, and the operator-facing owner-accounts endpoints (#19), per #14's
testing decision: assert on HTTP response + resulting session/DB state.
"""
import pytest
import app as app_module
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-OWNER-A"
CLIENT_B = "C-TEST-OWNER-B"
@pytest.fixture
def client(monkeypatch):
flask_app.config["TESTING"] = True
flask_app.secret_key = "test-secret"
monkeypatch.setattr(app_module, "CRM_TOKEN", "test-crm-token")
return flask_app.test_client()
def _insert_client(client_id):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, business_name) VALUES (%s, %s) "
"ON CONFLICT (client_id) DO NOTHING",
(client_id, "Café " + client_id))
conn.commit()
# ---- login ----
def test_login_succeeds_and_scopes_session_to_client_id(client):
_insert_client(CLIENT_A)
bdb.create_user(CLIENT_A, "owner@example.com", "correct horse")
resp = client.post("/owner/login", data={
"email": "owner@example.com", "password": "correct horse"})
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/owner/")
with client.session_transaction() as sess:
assert sess["client_id"] == CLIENT_A
dashboard = client.get("/owner/")
assert dashboard.status_code == 200
assert CLIENT_A in dashboard.get_data(as_text=True)
def test_login_rejects_wrong_password(client):
_insert_client(CLIENT_A)
bdb.create_user(CLIENT_A, "owner@example.com", "correct horse")
resp = client.post("/owner/login", data={
"email": "owner@example.com", "password": "wrong"})
assert resp.status_code == 401
with client.session_transaction() as sess:
assert "client_id" not in sess
def test_login_rejects_unknown_email(client):
resp = client.post("/owner/login", data={
"email": "nobody@example.com", "password": "whatever"})
assert resp.status_code == 401
def test_dashboard_requires_login(client):
resp = client.get("/owner/")
assert resp.status_code == 302
assert "/owner/login" in resp.headers["Location"]
def test_logout_clears_session(client):
_insert_client(CLIENT_A)
bdb.create_user(CLIENT_A, "owner@example.com", "correct horse")
client.post("/owner/login", data={
"email": "owner@example.com", "password": "correct horse"})
client.get("/owner/logout")
with client.session_transaction() as sess:
assert "client_id" not in sess
assert client.get("/owner/").status_code == 302
# ---- self-service password reset ----
def test_forgot_password_gives_same_response_for_unknown_email(client):
known = client.post("/owner/forgot-password", data={"email": "nobody@example.com"})
assert known.status_code == 200
_insert_client(CLIENT_A)
bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
real = client.post("/owner/forgot-password", data={"email": "owner@example.com"})
assert real.status_code == 200
assert known.get_data() == real.get_data()
def test_forgot_password_creates_a_usable_reset_token(client):
_insert_client(CLIENT_A)
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
client.post("/owner/forgot-password", data={"email": "owner@example.com"})
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT token FROM password_reset_tokens WHERE user_id = %s", (u["user_id"],))
token = cur.fetchone()["token"]
resp = client.post(f"/owner/reset-password/{token}", data={"password": "new-password"})
assert resp.status_code == 200
assert "geändert" in resp.get_data(as_text=True).lower()
refreshed = bdb.get_user(u["user_id"])
assert bdb.verify_password(refreshed, "new-password")
assert not bdb.verify_password(refreshed, "old-password")
def test_reset_password_token_is_single_use(client):
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
tok = bdb.create_password_reset_token(u["user_id"])
first = client.post(f"/owner/reset-password/{tok['token']}", data={"password": "new-password"})
assert first.status_code == 200
second = client.post(f"/owner/reset-password/{tok['token']}", data={"password": "another-one"})
assert second.status_code == 400
def test_reset_password_rejects_short_password(client):
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
tok = bdb.create_password_reset_token(u["user_id"])
resp = client.post(f"/owner/reset-password/{tok['token']}", data={"password": "short"})
assert resp.status_code == 400
refreshed = bdb.get_user(u["user_id"])
assert bdb.verify_password(refreshed, "old-password")
def test_reset_password_rejects_bad_token(client):
resp = client.post("/owner/reset-password/not-a-real-token", data={"password": "new-password"})
assert resp.status_code == 400
# ---- operator-facing endpoints ----
def test_list_owner_users_requires_crm_token(client):
assert client.get("/api/owner_users").status_code == 403
def test_list_owner_users_returns_rows_across_clients(client):
_insert_client(CLIENT_A)
_insert_client(CLIENT_B)
bdb.create_user(CLIENT_A, "a@example.com", "pw12345")
bdb.create_user(CLIENT_B, "b@example.com", "pw12345")
resp = client.get("/api/owner_users", headers={"X-CRM-Token": "test-crm-token"})
assert resp.status_code == 200
emails = {r["email"] for r in resp.get_json()["rows"]}
assert {"a@example.com", "b@example.com"} <= emails
def test_operator_triggered_reset_requires_crm_token(client):
u = bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
resp = client.post(f"/api/owner_users/{u['user_id']}/reset-password")
assert resp.status_code == 403
def test_operator_triggered_reset_creates_token_for_existing_user(client):
_insert_client(CLIENT_A)
u = bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
resp = client.post(f"/api/owner_users/{u['user_id']}/reset-password",
headers={"X-CRM-Token": "test-crm-token"})
assert resp.status_code == 200
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT count(*) AS n FROM password_reset_tokens WHERE user_id = %s",
(u["user_id"],))
assert cur.fetchone()["n"] == 1
def test_operator_triggered_reset_404s_for_unknown_user(client):
resp = client.post("/api/owner_users/U-does-not-exist/reset-password",
headers={"X-CRM-Token": "test-crm-token"})
assert resp.status_code == 404
+1
View File
@@ -25,6 +25,7 @@ services:
DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm
CRM_API_TOKEN: ${CRM_API_TOKEN} CRM_API_TOKEN: ${CRM_API_TOKEN}
BOOKING_TOKEN_SECRET: ${BOOKING_TOKEN_SECRET} BOOKING_TOKEN_SECRET: ${BOOKING_TOKEN_SECRET}
SESSION_SECRET_KEY: ${SESSION_SECRET_KEY}
ICS_TOKEN: ${ICS_TOKEN} ICS_TOKEN: ${ICS_TOKEN}
SHEET_ID: ${SHEET_ID} SHEET_ID: ${SHEET_ID}
GOOGLE_SA_JSON: /run/secrets/gcp-sa.json GOOGLE_SA_JSON: /run/secrets/gcp-sa.json