diff --git a/backoffice/.env.example b/backoffice/.env.example index 8fde97b..7e321a3 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -2,6 +2,9 @@ DB_PASSWORD=change-me-strong CRM_API_TOKEN=change-me-long-random 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 # Booking confirmation email (#18). Left blank, sending is skipped (logged, # not fatal) -- mail relay setup is a separate infra/triage item. diff --git a/backoffice/app/app.py b/backoffice/app/app.py index 4115c1d..5085f69 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -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//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).""" diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 44aaad3..69ab7e0 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -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) diff --git a/backoffice/app/owner_auth.py b/backoffice/app/owner_auth.py new file mode 100644 index 0000000..af1156b --- /dev/null +++ b/backoffice/app/owner_auth.py @@ -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/") +def reset_password(token): + return render_template( + "owner/reset_password.html", token=token, error=None, done=False) + + +@bp.post("/reset-password/") +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) diff --git a/backoffice/app/owner_mail.py b/backoffice/app/owner_mail.py new file mode 100644 index 0000000..57f2cbf --- /dev/null +++ b/backoffice/app/owner_mail.py @@ -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), + ) diff --git a/backoffice/app/static/index.html b/backoffice/app/static/index.html index 56b4ac1..df5c48c 100644 --- a/backoffice/app/static/index.html +++ b/backoffice/app/static/index.html @@ -82,6 +82,7 @@ +
@@ -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 = `${head}${body}
`; } + 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 = '
Keine Einträge.
'; return; } + const head = 'client_idemailcreated_at'; + const body = rows.map(r => { + const created = r.created_at ? String(r.created_at).replace('T',' ').slice(0,16) : ''; + return `${esc(r.client_id)}${esc(r.email)}` + + `${esc(created)}` + + ``; + }).join(''); + document.getElementById('table').innerHTML = `${head}${body}
`; + } + + 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; diff --git a/backoffice/app/templates/emails/password_reset.html b/backoffice/app/templates/emails/password_reset.html new file mode 100644 index 0000000..1d181a1 --- /dev/null +++ b/backoffice/app/templates/emails/password_reset.html @@ -0,0 +1,14 @@ + + + + + Passwort zurücksetzen + + +

{{ business_name }}

+

Sie haben ein neues Passwort für Ihr Konto angefordert (oder es wurde für Sie angefordert).

+

Über den folgenden Link können Sie ein neues Passwort vergeben:

+

{{ reset_url }}

+

Falls Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren.

+ + diff --git a/backoffice/app/templates/owner/dashboard.html b/backoffice/app/templates/owner/dashboard.html new file mode 100644 index 0000000..158fcf2 --- /dev/null +++ b/backoffice/app/templates/owner/dashboard.html @@ -0,0 +1,30 @@ + + + + + + + Mein Konto — {{ client.business_name if client else '' }} + + + +

{{ client.business_name if client else 'Mein Konto' }}

+
+

Sie sind angemeldet.

+

Kalender, Buchungen und Einstellungen folgen hier.

+
+

Abmelden

+ + diff --git a/backoffice/app/templates/owner/forgot_password.html b/backoffice/app/templates/owner/forgot_password.html new file mode 100644 index 0000000..addbf58 --- /dev/null +++ b/backoffice/app/templates/owner/forgot_password.html @@ -0,0 +1,46 @@ + + + + + + + Passwort vergessen + + + +

Passwort vergessen

+ {% if sent %} +
+

Wenn zu dieser E-Mail-Adresse ein Konto existiert, wurde ein Link zum Zurücksetzen + des Passworts versendet.

+

Bitte prüfen Sie Ihren Posteingang.

+
+ {% else %} +
+
+ + +
+ +
+ {% endif %} + + diff --git a/backoffice/app/templates/owner/login.html b/backoffice/app/templates/owner/login.html new file mode 100644 index 0000000..1bff952 --- /dev/null +++ b/backoffice/app/templates/owner/login.html @@ -0,0 +1,48 @@ + + + + + + + Anmelden + + + +

Anmelden

+ {% if error %}
{{ error }}
{% endif %} +
+
+ + +
+
+ + +
+ +
+

Passwort vergessen?

+ + diff --git a/backoffice/app/templates/owner/reset_password.html b/backoffice/app/templates/owner/reset_password.html new file mode 100644 index 0000000..fc8e256 --- /dev/null +++ b/backoffice/app/templates/owner/reset_password.html @@ -0,0 +1,51 @@ + + + + + + + Passwort zurücksetzen + + + +

Passwort zurücksetzen

+ {% if done %} +
+

Ihr Passwort wurde geändert.

+

Jetzt anmelden

+
+ {% else %} + {% if error %}
{{ error }}
{% endif %} +
+
+ + +
+ +
+ {% endif %} + + diff --git a/backoffice/app/tests/test_booking_db.py b/backoffice/app/tests/test_booking_db.py index 119a070..0e9be88 100644 --- a/backoffice/app/tests/test_booking_db.py +++ b/backoffice/app/tests/test_booking_db.py @@ -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] diff --git a/backoffice/app/tests/test_owner_auth.py b/backoffice/app/tests/test_owner_auth.py new file mode 100644 index 0000000..7eb7bcf --- /dev/null +++ b/backoffice/app/tests/test_owner_auth.py @@ -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 diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index d950d6b..0c1758d 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -25,6 +25,7 @@ services: DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm CRM_API_TOKEN: ${CRM_API_TOKEN} BOOKING_TOKEN_SECRET: ${BOOKING_TOKEN_SECRET} + SESSION_SECRET_KEY: ${SESSION_SECRET_KEY} ICS_TOKEN: ${ICS_TOKEN} SHEET_ID: ${SHEET_ID} GOOGLE_SA_JSON: /run/secrets/gcp-sa.json