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:
@@ -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)."""
|
||||
|
||||
@@ -273,6 +273,37 @@ def get_user_by_email(client_id, email):
|
||||
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):
|
||||
return check_password_hash(user["password_hash"], password)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -82,6 +82,7 @@
|
||||
<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="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 class="bar">
|
||||
<input id="search" placeholder="Filtern …" />
|
||||
@@ -144,6 +145,7 @@
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(current === 'owner_users'){ renderOwnerUsers(); return; }
|
||||
const cols = COLS[current];
|
||||
let rows = cache[current] || [];
|
||||
const q = document.getElementById('search').value.trim().toLowerCase();
|
||||
@@ -170,6 +172,31 @@
|
||||
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){
|
||||
if(revealed.has(id)) revealed.delete(id); else revealed.add(id);
|
||||
render();
|
||||
@@ -276,6 +303,7 @@
|
||||
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
current = t.dataset.e;
|
||||
document.getElementById('add').style.display = (current === 'owner_users') ? 'none' : '';
|
||||
if(cache[current]) render(); else load(current);
|
||||
});
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -234,3 +234,30 @@ def test_consume_expired_password_reset_token_fails():
|
||||
|
||||
def test_consume_unknown_token_fails():
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user