diff --git a/VERSION b/VERSION index 0f7dc0e..3420149 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1264 \ No newline at end of file +1265 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 1169b5d..dba33c8 100644 --- a/backend/database.py +++ b/backend/database.py @@ -627,6 +627,8 @@ def _migrate(conn_factory): ("users", "referred_qr", "TEXT"), # Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen) ("partner_codes", "owner_user_id", "INTEGER"), + # Notbremse für geleakte Codes: 0 = pausiert (Einlösung gesperrt, Historie bleibt) + ("partner_codes", "active", "INTEGER NOT NULL DEFAULT 1"), # Passwort-Zurücksetzen ("users", "password_reset_token", "TEXT"), ("users", "password_reset_expires", "TEXT"), diff --git a/backend/main.py b/backend/main.py index 309e184..4f35a98 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2165,12 +2165,7 @@ async def partner_qr_scan(token: str): token = token.strip() with _db() as conn: row = conn.execute( - """SELECT q.token, pc.code - FROM partner_qr_codes q - JOIN partner_qr_batches b ON b.id = q.batch_id - JOIN partner_codes pc ON pc.id = b.partner_code_id - WHERE q.token = ?""", - (token,) + "SELECT token FROM partner_qr_codes WHERE token = ?", (token,) ).fetchone() if not row: return _Redirect("/", status_code=302) @@ -2182,8 +2177,10 @@ async def partner_qr_scan(token: str): WHERE token = ?""", (token,) ) - # ?ref= nutzt den bestehenden Partner-Code-Flow, ?qr= ergänzt die Einzelcode-Zuordnung - return _Redirect(f"/?ref={row['code']}&qr={row['token']}", status_code=302) + # Bewusst NUR der Token in der URL — der tippbare Partner-Code bleibt verborgen + # (sonst könnte jeder Sticker-Scanner den Code ablesen und beliebig weitergeben). + # Die Registrierung löst den Code server-seitig aus dem Token auf. + return _Redirect(f"/?qr={row['token']}", status_code=302) # ------------------------------------------------------------------ diff --git a/backend/routes/auth.py b/backend/routes/auth.py index db5683d..403eae8 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -205,11 +205,26 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone() new_user_id = user["id"] - if data.ref_code: - code_upper = data.ref_code.strip().upper() - # Zuerst prüfen ob es ein Partner-Code ist + # QR-only-Flow: Die Scan-URL trägt bewusst KEINEN Klartext-Code mehr — + # der Partner-Code wird hier server-seitig aus dem QR-Token aufgelöst. + ref_code_in = data.ref_code + if not ref_code_in and data.qr_token: + qr_row = conn.execute( + """SELECT pc.code FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + JOIN partner_codes pc ON pc.id = b.partner_code_id + WHERE q.token=?""", + (data.qr_token.strip(),) + ).fetchone() + if qr_row: + ref_code_in = qr_row["code"] + + if ref_code_in: + code_upper = ref_code_in.strip().upper() + # Zuerst prüfen ob es ein Partner-Code ist (active=0 = Notbremse bei + # geleakten Codes: wird wie nicht existent behandelt, Historie bleibt) partner = conn.execute( - "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?", + "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=? AND active=1", (code_upper,) ).fetchone() if partner: diff --git a/backend/routes/partner.py b/backend/routes/partner.py index f7a61df..115517b 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -37,7 +37,7 @@ def list_partner_codes(user=Depends(require_admin)): with db() as conn: rows = conn.execute( """SELECT pc.id, pc.code, pc.label, pc.grants_founder, - pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id, + pc.max_uses, pc.uses, pc.created_at, pc.owner_user_id, pc.active, u.name AS created_by_name, o.name AS owner_name FROM partner_codes pc @@ -48,6 +48,21 @@ def list_partner_codes(user=Depends(require_admin)): return [dict(r) for r in rows] +@router.post("/admin/partner/codes/{code_id}/toggle") +def toggle_partner_code(code_id: int, user=Depends(require_admin)): + """Notbremse: Code pausieren/reaktivieren (z. B. wenn er im Internet kursiert). + Pausiert = Einlösung gesperrt, Stats und QR-Kontingente bleiben erhalten.""" + with db() as conn: + row = conn.execute( + "SELECT active FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Partner-Code nicht gefunden.") + new_state = 0 if row["active"] else 1 + conn.execute("UPDATE partner_codes SET active=? WHERE id=?", (new_state, code_id)) + return {"active": new_state} + + @router.post("/admin/partner/codes", status_code=201) def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)): """Neuen Partner-Code erstellen (admin only).""" @@ -197,7 +212,7 @@ def partner_code_info(code: str): with db() as conn: row = conn.execute( """SELECT code, label, grants_founder, max_uses, uses - FROM partner_codes WHERE code=?""", + FROM partner_codes WHERE code=? AND active=1""", (code.strip().upper(),) ).fetchone() if not row: diff --git a/backend/static/index.html b/backend/static/index.html index e462ddc..4d30aec 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -616,11 +616,11 @@ - - - - - + + + + + @@ -630,7 +630,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9ddb2b9..1903e4a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1264'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1265'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; @@ -1142,11 +1142,14 @@ const App = (() => { const urlParams = new URLSearchParams(window.location.search); const refCode = urlParams.get('ref'); const qrToken = urlParams.get('qr'); - if (refCode) { + if (refCode || qrToken) { try { - localStorage.setItem('by_ref_code', refCode.toUpperCase()); - localStorage.setItem('by_ref_code_ts', String(Date.now())); - // Partner-QR-Token (Sticker/Flyer) für Einzelcode-Rückverfolgung mitspeichern + if (refCode) { + localStorage.setItem('by_ref_code', refCode.toUpperCase()); + localStorage.setItem('by_ref_code_ts', String(Date.now())); + } + // Partner-QR-Token (Sticker/Flyer): kommt bewusst OHNE Klartext-Code — + // die Registrierung löst den Partner-Code server-seitig aus dem Token auf if (qrToken) localStorage.setItem('by_qr_token', qrToken); } catch {} // URL bereinigen ohne Reload diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index c2f5760..b07b51f 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2326,8 +2326,8 @@ window.Page_admin = (() => {
- - + +
{ ${codes.map(c => ` - + ${c.code} + ${c.active ? '' : `
⏸ pausiert
`} ${c.label} @@ -2379,7 +2380,12 @@ window.Page_admin = (() => { ${c.grants_founder ? '✓' : '—'} - + +
`; + // Code pausieren/aktivieren (Notbremse bei geleakten Codes) + el.querySelectorAll('.adm-toggle-code').forEach(btn => { + btn.addEventListener('click', async () => { + try { + const r = await API.post(`/admin/partner/codes/${btn.dataset.id}/toggle`, {}); + UI.toast.success(r.active ? 'Code wieder aktiv.' : 'Code pausiert — Einlösungen sind gesperrt.'); + await _renderPartner(el); + } catch (err) { UI.toast.error(err.message); } + }); + }); + // Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner) el.querySelectorAll('.adm-code-owner').forEach(btn => { btn.addEventListener('click', async () => { diff --git a/backend/static/landing.html b/backend/static/landing.html index 9f3741a..88ec792 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index bd8c2b2..3059fa7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1264'; +const VER = '1265'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index fe3eed2..73c2994 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -50,7 +50,9 @@ def test_scan_redirects_and_counts(client, admin): r = client.get(f"/q/{token}", follow_redirects=False) assert r.status_code == 302 - assert r.headers["location"] == f"/?ref={code['code']}&qr={token}" + # Bewusst KEIN Klartext-Code in der URL — sonst liest jeder Scanner den Code ab + assert r.headers["location"] == f"/?qr={token}" + assert code["code"] not in r.headers["location"] client.get(f"/q/{token}", follow_redirects=False) r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"]) @@ -104,6 +106,52 @@ def test_registration_attributed_to_qr(client, admin): assert regs[0]["created_at"] +def test_registration_with_qr_only(client, admin): + """Registrierung NUR mit qr_token (ohne ref_code) -> Code wird server-seitig aufgeloest.""" + code = _create_code(client, admin) + batch = _create_batch(client, admin, code["id"], quantity=1) + token = _batch_tokens(batch["id"])[0] + + email = f"qro-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qro{secrets.token_hex(3)}", + "qr_token": token, + }) + assert r.status_code == 200, r.text + from database import db + with db() as conn: + row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone() + assert row["referred_by"] == -code["id"] + assert row["referred_qr"] == token + + +def test_paused_code_not_redeemable(client, admin): + """Pausierter Code (Notbremse) -> keine Einloesung, Info-Endpoint 404; reaktivierbar.""" + code = _create_code(client, admin) + r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"]) + assert r.status_code == 200 and r.json()["active"] == 0 + + # Info-Endpoint: wie nicht existent + assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 404 + + # Registrierung mit pausiertem Code -> keine Zuordnung + email = f"qrp-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qrp{secrets.token_hex(3)}", + "ref_code": code["code"], + }) + assert r.status_code == 200, r.text + from database import db + with db() as conn: + row = conn.execute("SELECT referred_by FROM users WHERE email=?", (email,)).fetchone() + assert row["referred_by"] is None + + # Reaktivieren funktioniert + r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"]) + assert r.json()["active"] == 1 + assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 200 + + def test_qr_token_must_match_code(client, admin): """QR-Token eines FREMDEN Codes wird nicht zugeordnet (Manipulationsschutz).""" code_a = _create_code(client, admin)