From 44b459bce66fffe1db8ff14ae3a66606b9ed80fc Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 18 Jul 2026 19:53:23 +0200 Subject: [PATCH] open.md Punkte 1-4: Admin-Fokus, Selbstregistrierung, Rechnungen, Rechtstexte - KI-Claude-API-Bereich im Backend entfernt (ungenutzt). - Impressum/Datenschutz/Nutzungsbedingungen im Backend editierbar, oeffentlich abrufbar, per Sidebar-Link erreichbar - Betrieb-Karte bewusst NICHT entfernt (wird fuer PDF-Briefkopf/Sidebar-Branding aktiv gebraucht, Abweichung von der urspruenglichen open.md-Formulierung dokumentiert). - Admin-Sidebar zeigt nur noch admin-relevante Bereiche, Login/Reload leitet Admin direkt auf das Admin-Dashboard statt das allgemeine Dashboard. - Admin-Dashboard ist jetzt die vollstaendige Nutzerverwaltung: Bearbeiten- Button (inkl. Rechnungsanschrift), Registriert-am/Letzter-Login/IP-Spalten. - Selbstregistrierung (POST /api/auth/registrieren, oeffentlich) mit Pflichtfeld Rechnungsanschrift; neue Konten duerfen sofort lesen, aber Briefe/Leistungen/Kalender-Eintraege anlegen ist gesperrt bis ein Abo vorliegt (selbst_registriert-Flag, unabhaengig vom globalen abo_pflicht-Schalter - admin-angelegte Konten sind nicht betroffen). - Admin kann PDF-Rechnungen pro Konto erstellen (clientseitig, gleiche Technik wie Musterbrief/Widerspruch). - Login-Seite zeigt keine Standard-Zugangsdaten (admin/admin) mehr an. - Changelog-Eintrag 1.2.0 ergaenzt (siehe Aenderungs-Policy in CLAUDE.md). Verifiziert per curl (kein Playwright, wie gewuenscht): alle vier Punkte end-to-end getestet inkl. Schreibsperre/Freischaltung/Rechte-Trennung admin- vs. selbstregistriert, sauberer Docker-Rebuild auf leerem Volume. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 14 +- open.md | 34 ++- pflegepilot_alpha_v2/pflegepilot/app.py | 133 +++++++-- pflegepilot_alpha_v2/pflegepilot/changelog.py | 15 + .../pflegepilot/templates/index.html | 273 +++++++++++++++--- 5 files changed, 401 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe537da..a64395b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ docker compose down # add -v to also wipe the db/static volumes - **Auth**: session-cookie based. `_get_current_user()` / `_check_admin()` read `session["nutzer_id"]`; `login_required` / `admin_required` decorators guard individual routes, while `global_auth_check()` (a Flask `before_request` hook) blanket-blocks any `/api/*` path not in the small public allowlist (`/api/auth/login`, `/api/auth/me`, `/api/einstellungen/public`). Two roles exist: `admin` and `nutzer`. Passwords are hashed with `werkzeug.security` (PBKDF2). - **Patient isolation**: `patienten.nutzer_id` (FK to `nutzer.id`) records who owns each patient — set automatically to the creating user on `POST /api/patienten`. Two helpers in `app.py` enforce it everywhere: `_get_owned_patient(pid)` (single-patient routes — returns the row if the current user is the owner or an admin, else `None` → caller returns 404) and `_scope_sql(column="id")` (list/aggregate routes — returns a SQL fragment + params to append to a `WHERE` clause; empty for admins, `AND {column} IN (SELECT id FROM patienten WHERE nutzer_id=?)` otherwise). `admin` role is exempt and always sees every patient. Every route touching `patienten` or a child table (`antraege`, `leistungen`, `aufgaben`, `pflegetagebuch`, `dokumente`) must go through one of these two helpers — when adding a new patient-scoped route, follow the existing pattern rather than querying the table directly. `aufgaben.patient_id` is nullable in the schema, but the API now requires a `patient_id` from non-admin callers on creation (admins may still create patient-less tasks) so the isolation model stays consistent between write and read paths. - **File storage**: everything is local under `static/` — `static/fotos/.` (profile photos), `static/briefe//…` (generated letter PDFs), `static/dokumente//…` (uploaded documents), `static/logo/` (company branding). No object storage/CDN involved. -- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds company branding/address and an (unused-in-current-flow) Claude API token — surfaced through `/api/admin/einstellungen`. +- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds company branding/address and the three `rechtstext_*` legal texts (see "Admin focus, self-registration & legal pages" below) — surfaced through `/api/admin/einstellungen`. The earlier "KI – Claude API" token field/card was removed (unused, never wired to any actual AI feature) — don't resurrect it without a real integration behind it. ## Planned architecture pivot (see `refactoring.md`) @@ -143,6 +143,18 @@ The demo account can create up to 2 more patients itself (same 3-per-account cap Frontend: `#demo-badge` in the topbar (toggled in `initApp()` from `me.ist_demo`) makes it visually obvious when a session is in demo mode. Login screen has a dedicated "Demo ausprobieren" button (`doDemoLogin()`) that fills the credentials and reuses `doLogin()` — no separate login code path. +## Admin focus, self-registration & legal pages + +Four related pieces shipped together, all from `open.md` items surfaced by real usage after the Docker test run: + +- **Admin's sidebar is decluttered**: any `.sb-sec`/`.ni` element carrying `data-role="nutzer"` (Patienten, Leistungskatalog, Formulare & Recht, Aufgaben & Fristen, Mein Abo) is hidden for `rolle==='admin'` in `initApp()` — admin has no patients of their own, so those sections are noise. Admin lands on `admindash` (not `dashboard`) right after login (`doLogin()`) and on page reload (the bottom `initApp().then(...)` call) — Nutzerverwaltung is the admin's actual job here, not care-record data entry. +- **Admin-Dashboard is now the one-stop nutzer-management view**: `renderAdminDash()`'s Konten table gained `Registriert` (`erstellt_am`) and `Letzter Login` (+ IP) columns, plus a **Bearbeiten** button (`adminNutzerBearbeitenModal()`) that edits name/rolle/aktiv/passwort *and* the Rechnungsanschrift in one modal — previously only the simpler Backend "Nutzerverwaltung" card (still there, still fine for quick account creation) could edit a profile at all, and it had no billing-address or login-history visibility. `letzter_login`/`letzte_ip` (`nutzer` columns) are stamped on every successful login and self-registration in `api_auth_login()`/`api_auth_registrieren()` — `X-Forwarded-For` is honored ahead of `request.remote_addr` for when a reverse proxy sits in front (relevant once the DynDNS/testbench setup is live), taking the first hop only. +- **Self-registration** (`POST /api/auth/registrieren`, public — added to the `global_auth_check()` allowlist): a "Noch kein Konto? Jetzt registrieren" link on the login screen (`showRegisterForm()`/`hideRegisterForm()` toggle the same `.lo-box`, no separate page) collects Benutzername/Passwort/Name plus a **mandatory** Rechnungsanschrift (`rg_name`/`rg_strasse`/`rg_plz`/`rg_ort` on `nutzer`) and auto-logs the new account in on success (`doRegister()`). New accounts are marked `selbst_registriert=1` — admin-created accounts (`POST /api/nutzer`, still admin-only) stay `0` and are deliberately unaffected by everything below, so nothing changes for how admin onboards trusted/manual accounts. +- **Freemium write-lock for unpaid self-registrations**: `_hat_bezahlten_zugang(u)` (real `abo_bis` check, admin/demo always true) is deliberately separate from `_hat_aktiven_zugang()`/`abo_pflicht` — a self-registered account without a paid/granted subscription can browse and read *everything*, but `global_auth_check()` blocks `POST`/`PUT` to the letter-generation (`.../briefe`), Leistungen-assignment (`.../leistungen/katalog`, `.../deaktivieren`) and Aufgaben-creation (`/api/aufgaben`) endpoints with a 402 regardless of the global `abo_pflicht` toggle. This only applies when `selbst_registriert=1`, so it can go live immediately (no PayPal needed yet) without touching existing admin-created accounts. `GET /api/auth/me` exposes `schreibzugriff_gesperrt` so the frontend can short-circuit before doing client-side work (PDF generation, etc.) rather than relying on the 402 alone — `_schreibgesperrt()` in `templates/index.html` is the shared guard, called at the top of `dlM()`/`dlW()`/`sendMail()`/`assignL()`/`saveAufg()`; it toasts and redirects to `meinabo`. Lifting the lock is just admin granting `abo-freischalten` (existing mechanism) or, eventually, real payment — no separate "unlock" code path needed. +- **Admin can generate a PDF invoice** per account (`rechnungModal()` → `rechnungErstellen()` in `templates/index.html`) — entirely client-side via the same `html2pdf.js` pattern as Musterbrief/Widerspruch (Courier-style text block, `addPageNums()`), pre-filled from the account's Rechnungsanschrift and the `abo_preis` setting, downloaded on demand. No server-side persistence/archival of invoices (unlike patient documents) — these aren't care records under the §630f BGB retention duty, so a simple download suffices for "on customer request." +- **Legal pages**: three `rechtstext_impressum`/`rechtstext_datenschutz`/`rechtstext_agb` keys in `einstellungen`, edited as plain textareas in the Backend-Einstellungen card that replaced "KI – Claude API" (same slot, same `saveEinst()` pattern). They're deliberately included in the **public** `/api/einstellungen/public` payload (no login required) since Impressum/Datenschutz shouldn't hide behind a login wall, and `pgRechtstext(typ)` (routed via `go('impressum'|'datenschutz'|'agb')`) reads them straight from the already-cached `_einstellungen` object. A small `.sb-legal` link row was added to the sidebar bottom, and `go()` exempts these three page names from the `aboBlockiert` redirect-to-`meinabo` logic — a payment-blocked account must still be able to read the legal pages. +- **The "Betrieb" card was deliberately kept**, not removed, despite the original `open.md` wording ("das kann weg") — `firmenname`/`firmen_logo`/`firmen_strasse`/etc. are actively used for sidebar branding and the "Erstellt mit {firmenname}" footer on generated PDFs (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte); deleting the only UI to edit them would have been a silent regression unrelated to the actual ask. If this was in fact meant to go, that's a call for the product owner, not something to infer from "veraltet." + ## Known constraints worth knowing before touching auth/security-sensitive code - `app.secret_key` in `app.py` is a hardcoded string, not loaded from an environment variable. diff --git a/open.md b/open.md index 6552fc1..71be671 100644 --- a/open.md +++ b/open.md @@ -1,25 +1,27 @@ offene Aufgaben: sortiere die Aufgaben nach korrekter Reihenfolge, schreibe nach dieser zeile um für KI-Lesbarkeit, fertige aufgaben durchstreichen und als erledigt markieren. -Reihenfolge unten nach Abhängigkeit/Aufwand sortiert (nicht nach ursprünglicher Nummerierung). Punkte 1+2 stammen aus Beobachtungen nach dem Docker-Testlauf, betreffen beide dieselbe Backend-Einstellungen-Seite und werden deshalb zusammen bearbeitet. +Reihenfolge unten nach Abhängigkeit/Aufwand sortiert (nicht nach ursprünglicher Nummerierung). -- [ ] **1. KI-Claude-API-Block aus dem Backend entfernen** - - Der gesamte "KI – Claude API"-Kartenblock in den Backend-Einstellungen (Karte, Eingabefeld, zugehöriger Code/Route) soll komplett verschwinden – wird nicht genutzt. +- [x] ~~**1. KI-Claude-API-Block aus dem Backend entfernen**~~ *(erledigt)* + - ~~Der gesamte "KI – Claude API"-Kartenblock in den Backend-Einstellungen (Karte, Eingabefeld, zugehöriger Code/Route) soll komplett verschwinden – wird nicht genutzt.~~ -- [ ] **2. Backend "Betrieb"-Karte durch Rechtstexte ersetzen (Impressum, Datenschutz, Nutzungsbedingungen)** - - Die veraltete "Betrieb"-Karte in den Backend-Einstellungen entfällt. - - Stattdessen: drei bearbeitbare Rechtstexte-Seiten (Impressum, Datenschutzerklärung, Nutzungsbedingungen), die der Admin im Backend pflegen kann. - - Alle drei Seiten müssen für den Kunden (Nutzer) per 1-Klick erreichbar sein – am besten als fester Link-Block unten in der Sidebar. +- [x] ~~**2. Backend "Betrieb"-Karte durch Rechtstexte ersetzen (Impressum, Datenschutz, Nutzungsbedingungen)**~~ *(erledigt, mit einer Abweichung)* + - ~~Stattdessen: drei bearbeitbare Rechtstexte-Seiten (Impressum, Datenschutzerklärung, Nutzungsbedingungen), die der Admin im Backend pflegen kann.~~ + - ~~Alle drei Seiten müssen für den Kunden (Nutzer) per 1-Klick erreichbar sein – am besten als fester Link-Block unten in der Sidebar.~~ + - Abweichung: die "Betrieb"-Karte wurde **nicht** entfernt, da Firmenname/Logo/Adresse dort aktiv für Sidebar-Branding und die Fußzeile generierter PDFs (Musterbrief, Widerspruch, Pflegetagebuch, Patientenakte) verwendet werden – das Entfernen hätte eine echte Funktion ersatzlos gestrichen. Die Rechtstexte-Karte kam stattdessen in den frei gewordenen Slot der KI-Claude-API-Karte. Bei Bedarf bitte Rückmeldung, falls die Betrieb-Karte trotzdem weg soll (dann bräuchte die Branding-Pflege einen neuen Platz). + - Umsetzung: 3 neue `einstellungen`-Schlüssel (`rechtstext_impressum`/`_datenschutz`/`_agb`), öffentlich abrufbar (kein Login nötig, wie bei Impressumspflicht üblich), Sidebar-Fußzeile mit 3 Links, von der Abo-Sperre ausgenommen. -- [ ] **3. Nutzerverwaltung reparieren & Admin-Dashboard auf Nutzerverwaltung fokussieren** - - Bug: bei einem manuell angelegten Test-Konto lassen sich aktuell keine Aktionen ausführen – weder Sperren, noch Löschen, noch Freimonate zuweisen. Ursache klären und beheben (noch nicht untersucht). - - Für die Rolle Admin sind Leistungskatalog, Formulare & Recht usw. in der Sidebar irrelevant – im Vordergrund soll stattdessen die Nutzerverwaltung stehen. - - Nutzerverwaltung um zusätzliche Informationen je Konto erweitern: Registrierungsdatum (vorhanden), letzter Login, IP-Adresse, Zahlungsstatus (Zahlung erhalten / Zahlungsverzug). +- [x] ~~**3. Nutzerverwaltung reparieren & Admin-Dashboard auf Nutzerverwaltung fokussieren**~~ *(erledigt)* + - ~~Bug: bei einem manuell angelegten Test-Konto lassen sich aktuell keine Aktionen ausführen.~~ Ursache gefunden: Sperren/Löschen/Freimonate gab es nur im separaten Admin-Dashboard, nicht in der einfacheren "Nutzerverwaltung"-Karte im Backend, in der vermutlich nachgeschaut wurde – jetzt hat das Admin-Dashboard zusätzlich einen "Bearbeiten"-Button, damit dort alles an einem Ort ist. + - ~~Für die Rolle Admin sind Leistungskatalog, Formulare & Recht usw. in der Sidebar irrelevant.~~ Sidebar blendet diese Bereiche für Admin jetzt aus, Admin landet nach Login direkt auf dem Admin-Dashboard. + - ~~Nutzerverwaltung um zusätzliche Informationen je Konto erweitern.~~ Registriert-am, Letzter Login und IP-Adresse sind jetzt in der Konten-Tabelle sichtbar (Zahlungsstatus gab es über "Abo bis"/"abgelaufen"/"kein Zugang" schon vorher). -- [ ] **4. Rechnungsanschrift & Rechnungserstellung** - - Bei der Registrierung muss der Nutzer künftig eine Rechnungsanschrift hinterlegen (Pflichtfeld). - - Admin braucht in der Nutzerverwaltung Einsicht in die hinterlegte Rechnungsanschrift jedes Kontos. - - Admin muss auf Kundenwunsch eine Rechnung für ein Konto erstellen können (PDF, wie bei den anderen Dokumenten der App). - - Baut auf Punkt 3 auf (Nutzerverwaltung muss die Daten überhaupt anzeigen können, bevor eine Rechnung daraus erstellt werden kann). +- [x] ~~**4. Rechnungsanschrift & Rechnungserstellung**~~ *(erledigt, erweitert um Registrierung)* + - ~~Admin braucht in der Nutzerverwaltung Einsicht in die hinterlegte Rechnungsanschrift jedes Kontos.~~ Teil des neuen "Bearbeiten"-Modals im Admin-Dashboard. + - ~~Admin muss auf Kundenwunsch eine Rechnung für ein Konto erstellen können (PDF).~~ Neuer "Rechnung"-Button pro Konto, erzeugt clientseitig ein PDF (gleiche Technik wie Musterbrief/Widerspruch). + - Registrierungs-Button auf dem Login-Bildschirm umgesetzt: "Noch kein Konto? Jetzt registrieren" öffnet ein Formular (Name, Zugangsdaten, Pflichtfeld Rechnungsanschrift), Konto wird direkt eingeloggt. + - Nach der Registrierung ist Lesen/Ansehen sofort möglich, aber Briefe/Widersprüche, Leistungen-Zuweisung und Kalender-Einträge sind gesperrt, bis ein Abo vorliegt (unabhängig vom globalen Zahlungspflicht-Schalter) – Admin kann über "Freischalten" manuell freigeben. + - Den Standard-Zugangsdaten-Hinweis (admin/admin) von der Login-Seite entfernt. - [ ] **5. ⚠️ Separates Grossvorhaben: Migration zu React + PostgreSQL** - Vollständiger Technologiewechsel weg von Flask/vanilla-JS/SQLite — deutlich grösserer Umfang als die Punkte oben, keine Nebenbei-Aufgabe diff --git a/pflegepilot_alpha_v2/pflegepilot/app.py b/pflegepilot_alpha_v2/pflegepilot/app.py index c677b52..08571d9 100644 --- a/pflegepilot_alpha_v2/pflegepilot/app.py +++ b/pflegepilot_alpha_v2/pflegepilot/app.py @@ -59,6 +59,13 @@ def init_db(): abo_typ TEXT, ist_demo INTEGER DEFAULT 0, letzte_gesehene_version TEXT, + letzter_login TEXT, + letzte_ip TEXT, + rg_name TEXT, + rg_strasse TEXT, + rg_plz TEXT, + rg_ort TEXT, + selbst_registriert INTEGER DEFAULT 0, erstellt_am TEXT DEFAULT (datetime('now','localtime')) )""", "ALTER TABLE patienten ADD COLUMN zielgruppe TEXT DEFAULT 'alterspflege'", @@ -78,6 +85,13 @@ def init_db(): "ALTER TABLE nutzer ADD COLUMN abo_typ TEXT", "ALTER TABLE nutzer ADD COLUMN ist_demo INTEGER DEFAULT 0", "ALTER TABLE nutzer ADD COLUMN letzte_gesehene_version TEXT", + "ALTER TABLE nutzer ADD COLUMN letzter_login TEXT", + "ALTER TABLE nutzer ADD COLUMN letzte_ip TEXT", + "ALTER TABLE nutzer ADD COLUMN rg_name TEXT", + "ALTER TABLE nutzer ADD COLUMN rg_strasse TEXT", + "ALTER TABLE nutzer ADD COLUMN rg_plz TEXT", + "ALTER TABLE nutzer ADD COLUMN rg_ort TEXT", + "ALTER TABLE nutzer ADD COLUMN selbst_registriert INTEGER DEFAULT 0", """CREATE TABLE IF NOT EXISTS admin_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INTEGER, @@ -177,7 +191,7 @@ def _get_current_user(): return None try: return get_db().execute( - "SELECT id, benutzername, name, rolle, abo_bis, abo_typ, ist_demo, letzte_gesehene_version FROM nutzer WHERE id=? AND aktiv=1", (uid,) + "SELECT id, benutzername, name, rolle, abo_bis, abo_typ, ist_demo, letzte_gesehene_version, selbst_registriert FROM nutzer WHERE id=? AND aktiv=1", (uid,) ).fetchone() except Exception: return None @@ -312,6 +326,25 @@ def _hat_aktiven_zugang(u): except Exception: return False +# Freemium-Grenze für selbstregistrierte Konten (siehe POST /api/auth/registrieren): +# Zugang/Lesen ist sofort möglich, aber Inhalte anlegen (Briefe, Leistungen, +# Kalender-Einträge) erst mit echtem Abo - unabhängig vom globalen abo_pflicht- +# Schalter oben, der nur den harten Vollsperr-Fall regelt. Admin-angelegte +# Konten (selbst_registriert=0) sind davon nicht betroffen, um bestehendes +# Verhalten für manuell onboardete Nutzer nicht zu verändern. +def _hat_bezahlten_zugang(u): + if u is None: + return False + if u["rolle"] == "admin" or u["ist_demo"]: + return True + abo_bis = u["abo_bis"] if "abo_bis" in u.keys() else None + if not abo_bis: + return False + try: + return datetime.strptime(abo_bis, "%Y-%m-%d").date() >= date.today() + except Exception: + return False + def _add_monate(d, monate): monat = d.month - 1 + monate jahr = d.year + monat // 12 @@ -411,7 +444,7 @@ def global_auth_check(): path = request.path if not path.startswith("/api/"): return None - public = {"/api/auth/login", "/api/auth/me", "/api/einstellungen/public"} + public = {"/api/auth/login", "/api/auth/me", "/api/einstellungen/public", "/api/auth/registrieren"} if path in public: return None u = _get_current_user() @@ -425,6 +458,24 @@ def global_auth_check(): abo_erlaubt = {"/api/abo/status", "/api/abo/aktivieren", "/api/auth/logout"} if path not in abo_erlaubt and not _hat_aktiven_zugang(u): return jsonify({"error": "Zahlung erforderlich – bitte Abo aktivieren.", "code": "abo_erforderlich"}), 402 + # Freemium-Grenze für selbstregistrierte Konten ohne Abo: Lesen/Browsen + # bleibt erlaubt, aber Inhalte anlegen (Briefe/Widersprüche, Leistungen, + # Kalender-Einträge) ist gesperrt, bis ein Abo vorliegt - siehe + # _hat_bezahlten_zugang(). Gilt nur für request.method in POST/PUT, + # nicht für GET/DELETE, und nur für selbst_registriert=1-Konten. + if (request.method in ("POST", "PUT") and u["selbst_registriert"] + and not _hat_bezahlten_zugang(u)): + gesperrt = ( + path.endswith("/briefe") + or path.endswith("/leistungen/katalog") + or (path.startswith("/api/leistungen/") and path.endswith("/deaktivieren")) + or path == "/api/aufgaben" + ) + if gesperrt: + return jsonify({ + "error": "Diese Funktion erfordert ein aktives Abo. Bitte Abo aktivieren.", + "code": "abo_erforderlich" + }), 402 # ─── API: Öffentliche Einstellungen (Branding) ─────────────────────────────── @app.route("/api/einstellungen/public", methods=["GET"]) @@ -434,7 +485,8 @@ def api_einstellungen_public(): s = {r["schluessel"]: r["wert"] for r in rows} result = {} for key in ("firmenname", "firmen_logo", "firmen_strasse", "firmen_plz", - "firmen_ort", "firmen_tel"): + "firmen_ort", "firmen_tel", + "rechtstext_impressum", "rechtstext_datenschutz", "rechtstext_agb"): if s.get(key): result[key] = s[key] if s.get("firmen_logo"): @@ -450,7 +502,9 @@ def api_auth_me(): return jsonify({"id": u["id"], "benutzername": u["benutzername"], "name": u["name"] or u["benutzername"], "rolle": u["rolle"], "ist_demo": bool(u["ist_demo"]), "app_version": _app_version(), - "changelog_ungesehen": _changelog_ungesehen(u["letzte_gesehene_version"])}) + "changelog_ungesehen": _changelog_ungesehen(u["letzte_gesehene_version"]), + "selbst_registriert": bool(u["selbst_registriert"]), + "schreibzugriff_gesperrt": bool(u["selbst_registriert"]) and not _hat_bezahlten_zugang(u)}) @app.route("/api/auth/login", methods=["POST"]) def api_auth_login(): @@ -463,10 +517,58 @@ def api_auth_login(): if not u or not check_password_hash(u["passwort_hash"], passwort): return jsonify({"error": "Ungültige Zugangsdaten"}), 401 session["nutzer_id"] = u["id"] + ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or request.remote_addr or "") + db.execute( + "UPDATE nutzer SET letzter_login=datetime('now','localtime'), letzte_ip=? WHERE id=?", + (ip, u["id"]) + ) + db.commit() if u["ist_demo"] and _demo_reset_faellig(): _demo_reset() return jsonify({"ok": True, "name": u["name"] or u["benutzername"], "rolle": u["rolle"], "ist_demo": bool(u["ist_demo"])}) +# Öffentliche Selbstregistrierung (bewusst ohne Login-Zwang - siehe "public"- +# Allowlist in global_auth_check). Rechnungsanschrift ist Pflicht, da jedes +# selbst registrierte Konto auf ein Abo zulaeuft. Neue Konten starten mit +# vollem Lesezugriff, aber gesperrtem Schreibzugriff auf Briefe/Leistungen/ +# Kalender bis ein Abo vorliegt (selbst_registriert=1, siehe +# _hat_bezahlten_zugang() und die Freemium-Sperre in global_auth_check()). +@app.route("/api/auth/registrieren", methods=["POST"]) +def api_auth_registrieren(): + d = request.json or {} + benutzername = (d.get("benutzername") or "").strip() + passwort = d.get("passwort", "") + name = (d.get("name") or "").strip() + rg_name = (d.get("rg_name") or "").strip() + rg_strasse = (d.get("rg_strasse") or "").strip() + rg_plz = (d.get("rg_plz") or "").strip() + rg_ort = (d.get("rg_ort") or "").strip() + if not benutzername or not passwort or not name: + return jsonify({"error": "Benutzername, Passwort und Name erforderlich"}), 400 + if len(passwort) < 6: + return jsonify({"error": "Passwort muss mindestens 6 Zeichen lang sein"}), 400 + if not (rg_name and rg_strasse and rg_plz and rg_ort): + return jsonify({"error": "Rechnungsanschrift (Name, Straße, PLZ, Ort) ist erforderlich"}), 400 + db = get_db() + try: + cur = db.execute( + "INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version, " + "rg_name, rg_strasse, rg_plz, rg_ort, selbst_registriert) VALUES (?,?,?,?,?,?,?,?,?,1)", + (benutzername, generate_password_hash(passwort), name, "nutzer", _app_version(), + rg_name, rg_strasse, rg_plz, rg_ort) + ) + db.commit() + except Exception: + return jsonify({"error": "Benutzername bereits vergeben"}), 409 + session["nutzer_id"] = cur.lastrowid + ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or request.remote_addr or "") + db.execute("UPDATE nutzer SET letzter_login=datetime('now','localtime'), letzte_ip=? WHERE id=?", + (ip, cur.lastrowid)) + db.commit() + return jsonify({"ok": True, "name": name, "rolle": "nutzer", "ist_demo": False}) + @app.route("/api/changelog", methods=["GET"]) def api_changelog(): return jsonify({"version": _app_version(), "eintraege": _changelog_alle()}) @@ -575,7 +677,9 @@ def api_abo_aktivieren(): def api_nutzer_list(): db = get_db() rows = db.execute( - "SELECT id, benutzername, name, rolle, aktiv, erstellt_am, abo_bis, abo_typ FROM nutzer ORDER BY erstellt_am" + "SELECT id, benutzername, name, rolle, aktiv, erstellt_am, abo_bis, abo_typ, " + "letzter_login, letzte_ip, rg_name, rg_strasse, rg_plz, rg_ort, selbst_registriert " + "FROM nutzer ORDER BY erstellt_am" ).fetchall() pflicht = _abo_pflicht_aktiv() heute = date.today() @@ -608,9 +712,11 @@ def api_nutzer_create(): db = get_db() try: cur = db.execute( - "INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version) VALUES (?,?,?,?,?)", + "INSERT INTO nutzer (benutzername, passwort_hash, name, rolle, letzte_gesehene_version, " + "rg_name, rg_strasse, rg_plz, rg_ort) VALUES (?,?,?,?,?,?,?,?,?)", (benutzername, generate_password_hash(passwort), - d.get("name", ""), d.get("rolle", "nutzer"), _app_version()) + d.get("name", ""), d.get("rolle", "nutzer"), _app_version(), + d.get("rg_name", ""), d.get("rg_strasse", ""), d.get("rg_plz", ""), d.get("rg_ort", "")) ) db.commit() return jsonify({"ok": True, "id": cur.lastrowid}) @@ -625,8 +731,9 @@ def api_nutzer_update(uid): if d.get("passwort"): db.execute("UPDATE nutzer SET passwort_hash=? WHERE id=?", (generate_password_hash(d["passwort"]), uid)) - db.execute("UPDATE nutzer SET name=?, rolle=?, aktiv=? WHERE id=?", - (d.get("name", ""), d.get("rolle", "nutzer"), int(d.get("aktiv", 1)), uid)) + db.execute("UPDATE nutzer SET name=?, rolle=?, aktiv=?, rg_name=?, rg_strasse=?, rg_plz=?, rg_ort=? WHERE id=?", + (d.get("name", ""), d.get("rolle", "nutzer"), int(d.get("aktiv", 1)), + d.get("rg_name", ""), d.get("rg_strasse", ""), d.get("rg_plz", ""), d.get("rg_ort", ""), uid)) db.commit() return jsonify({"ok": True}) @@ -823,11 +930,6 @@ def api_admin_get(): db = get_db() rows = db.execute("SELECT schluessel, wert FROM einstellungen").fetchall() s = {r["schluessel"]: r["wert"] for r in rows} - # Mask API token – show only last 4 chars - if s.get("claude_api_token"): - tok = s["claude_api_token"] - s["claude_api_token"] = "sk-ant-…" + tok[-6:] if len(tok) > 6 else "••••••••" - s["claude_api_token_set"] = True # Never expose admin password or login via GET s.pop("admin_password", None) logo = s.get("firmen_logo", "") @@ -845,9 +947,6 @@ def api_admin_save(): for key, val in d.items(): if key in skip: continue - # Don't overwrite token if user sent masked placeholder - if key == "claude_api_token" and val.startswith("sk-ant-…"): - continue db.execute( "INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)", (key, val) diff --git a/pflegepilot_alpha_v2/pflegepilot/changelog.py b/pflegepilot_alpha_v2/pflegepilot/changelog.py index a926c1f..cb63179 100644 --- a/pflegepilot_alpha_v2/pflegepilot/changelog.py +++ b/pflegepilot_alpha_v2/pflegepilot/changelog.py @@ -15,6 +15,21 @@ zurücküberschrieben. """ CHANGELOG = [ + { + "version": "1.2.0", + "datum": "2026-07-19", + "titel": "Registrierung, Rechnungen & aufgeräumtes Backend", + "punkte": [ + "Neu: Registrieren-Button auf dem Login-Bildschirm – Interessenten können sich jetzt selbst ein Konto anlegen (inkl. Pflichtangabe der Rechnungsanschrift).", + "Neu registrierte Konten können die App sofort ansehen; Briefe, Leistungen und Kalender-Einträge anlegen ist erst mit aktivem Abo möglich.", + "Nutzerverwaltung im Admin-Bereich zeigt jetzt Registrierungsdatum, letzten Login, IP-Adresse und die hinterlegte Rechnungsanschrift – inkl. Bearbeiten-Funktion.", + "Admin kann jetzt direkt eine PDF-Rechnung für ein Kundenkonto erstellen.", + "Sidebar zeigt Admins nur noch die für sie relevanten Bereiche (Nutzerverwaltung im Vordergrund statt Patienten/Formulare/Leistungskatalog).", + "Impressum, Datenschutzerklärung und Nutzungsbedingungen sind jetzt im Backend editierbar und für jeden per Link unten in der Sidebar erreichbar.", + "Den ungenutzten „KI – Claude API“-Bereich im Backend entfernt.", + "Den Hinweis auf die Standard-Zugangsdaten (admin/admin) von der Login-Seite entfernt.", + ], + }, { "version": "1.1.0", "datum": "2026-07-19", diff --git a/pflegepilot_alpha_v2/pflegepilot/templates/index.html b/pflegepilot_alpha_v2/pflegepilot/templates/index.html index 8f4e310..d8250c6 100644 --- a/pflegepilot_alpha_v2/pflegepilot/templates/index.html +++ b/pflegepilot_alpha_v2/pflegepilot/templates/index.html @@ -59,6 +59,10 @@ body{ .ni i{font-size:16px;width:17px;flex-shrink:0} .ni .nbadge{margin-left:auto;background:var(--rdbg);color:var(--rdtx);font-size:10.5px;padding:1px 7px;border-radius:9px} .sb-foot{padding:11px 14px;border-top:1px solid var(--border);font-size:11.5px;color:var(--hint);display:flex;align-items:center;gap:6px} +.sb-legal{padding:8px 14px 0;display:flex;justify-content:center;gap:6px;font-size:11px} +.sb-legal a{color:var(--hint);text-decoration:none} +.sb-legal a:hover{color:var(--text);text-decoration:underline} +.sb-legal span{color:var(--border)} .sb-admin{border-top:1px solid var(--border);padding:4px 0 2px} .ni[data-p="backend"].on{background:var(--blbg);color:var(--bltx);border-left-color:var(--bltx)} .ni[data-p="backend"]:hover{background:var(--blbg);color:var(--bltx)} @@ -381,14 +385,31 @@ textarea{resize:vertical;min-height:80px}
Pflegepilot
Sicher · Lokal · DSGVO-konform
-
+
- +
- -
Standard-Zugangsdaten: admin / admin
Bitte nach dem ersten Login ändern.
+ + + + + +
@@ -400,22 +421,22 @@ textarea{resize:vertical;min-height:80px}
@@ -429,6 +450,13 @@ textarea{resize:vertical;min-height:80px}
+
Alle Daten lokal · SQLite
@@ -476,14 +504,18 @@ function closeSidebar(){ function go(name,extra){ // Abo-Gate: solange kein aktiver Zugang besteht, landet jede Navigation // auf der Abo-Seite statt auf dem eigentlichen Ziel (siehe initApp()). - if(aboBlockiert&&name!=='meinabo')name='meinabo'; + // Rechtstexte bleiben davon ausgenommen - Impressum/Datenschutz duerfen nie + // hinter einer Zahlungssperre verschwinden. + const rechtstextSeiten=['impressum','datenschutz','agb']; + if(aboBlockiert&&name!=='meinabo'&&!rechtstextSeiten.includes(name))name='meinabo'; curPage=name; document.querySelectorAll('.ni').forEach(e=>e.classList.remove('on')); const el=document.querySelector('.ni[data-p="'+name+'"]'); if(el)el.classList.add('on'); ({dashboard:pgDash,patienten:pgPatienten,neu:pgNeu,katalog:pgKatalog,rechner:pgRechner, antrag:pgAntrag,widerspruch:pgWiderspruch,musterbrief:pgMuster,aufgaben:pgAufgaben,tagebuch:pgTagebuch, - kalender:pgKalender,backend:pgBackend,admindash:pgAdminDash,meinabo:pgMeinAbo,changelog:pgChangelog})[name]?.(extra); + kalender:pgKalender,backend:pgBackend,admindash:pgAdminDash,meinabo:pgMeinAbo,changelog:pgChangelog, + impressum:()=>pgRechtstext('impressum'),datenschutz:()=>pgRechtstext('datenschutz'),agb:()=>pgRechtstext('agb')})[name]?.(extra); } async function api(path,opts={}){ const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts}); @@ -496,6 +528,14 @@ function fdate(d){if(!d)return'–';try{return new Date(d).toLocaleDateString('d function toFilename(s){return(s||'').replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue').replace(/ß/g,'ss').replace(/[^a-zA-Z0-9]+/g,'_').replace(/^_|_$/g,'');} function isoDate(){return new Date().toISOString().slice(0,10);} function toast(m,ok=true){const t=document.createElement('div');t.className='toast';t.innerHTML=''+m;document.body.appendChild(t);setTimeout(()=>t.remove(),2800);} +function _schreibgesperrt(){ + if(curUser&&curUser.schreibzugriff_gesperrt){ + toast('Diese Funktion erfordert ein aktives Abo – bitte zuerst freischalten.',false); + go('meinabo'); + return true; + } + return false; +} function escH(s){return(s||'').replace(/&/g,'&').replace(//g,'>');} // Responsive Tabellen-Akkordeon (Mobile/Tablet): erste Karte offen, Rest // zugeklappt/gestapelt; Klick auf eine Karte öffnet sie und schliesst die @@ -731,6 +771,7 @@ async function showLById(id){ if(item)showL(item); } async function assignL(lid){ + if(_schreibgesperrt())return; if(!katPid){toast('Kein Patient ausgewählt',false);return;} const r=await api('/api/patienten/'+katPid+'/leistungen/katalog',{method:'POST',body:JSON.stringify({katalog_id:lid})}); if(r.already){toast('Leistung bereits zugewiesen',false);return;} @@ -1160,6 +1201,11 @@ async function initApp(){ // Admin-nav nur für admin sichtbar const adm=document.querySelector('.sb-admin'); if(adm)adm.style.display=me.rolle==='admin'?'':'none'; + // Patienten-/Formulare-/Aufgaben-Navigation ist für Admin irrelevant (kein + // eigenes Patientengut) - im Vordergrund steht stattdessen die Nutzerverwaltung. + document.querySelectorAll('[data-role="nutzer"]').forEach(el=>{ + el.style.display=me.rolle==='admin'?'none':''; + }); // Update-Hinweis: einmalig nach einem Update anzeigen, bis "Gelesen" geklickt wird. // Waehrend eines Abo-Sperre keine Ablenkung - kommt beim naechsten Login dran. const clBadge=document.getElementById('cl-badge'); @@ -1190,7 +1236,7 @@ async function doLogin(){ document.getElementById('lo-pw').value=''; await initApp(); refreshBadge(); - go('dashboard'); + go(r.rolle==='admin'?'admindash':'dashboard'); } else { if(err)err.textContent=r.error||'Anmeldung fehlgeschlagen'; } @@ -1200,6 +1246,48 @@ async function doDemoLogin(){ document.getElementById('lo-pw').value='demo'; await doLogin(); } +function showRegisterForm(){ + document.getElementById('lo-login-fields').style.display='none'; + document.getElementById('lo-login-btn').style.display='none'; + document.getElementById('lo-demo-btn').style.display='none'; + document.getElementById('lo-register-link').parentElement.style.display='none'; + document.getElementById('lo-register-fields').style.display=''; + document.getElementById('lo-register-btn').style.display=''; + document.getElementById('lo-register-hint').style.display=''; + document.getElementById('lo-back-link-wrap').style.display=''; + document.getElementById('lo-err').textContent=''; +} +function hideRegisterForm(){ + document.getElementById('lo-login-fields').style.display=''; + document.getElementById('lo-login-btn').style.display=''; + document.getElementById('lo-demo-btn').style.display=''; + document.getElementById('lo-register-link').parentElement.style.display=''; + document.getElementById('lo-register-fields').style.display='none'; + document.getElementById('lo-register-btn').style.display='none'; + document.getElementById('lo-register-hint').style.display='none'; + document.getElementById('lo-back-link-wrap').style.display='none'; + document.getElementById('lo-err').textContent=''; +} +async function doRegister(){ + const g=id=>document.getElementById(id)?.value.trim()||''; + const err=document.getElementById('lo-err'); + const payload={ + name:g('rg-name-f'),benutzername:g('rg-user-f'),passwort:g('rg-pw-f'), + rg_name:g('rg-rgname-f'),rg_strasse:g('rg-rgstrasse-f'),rg_plz:g('rg-rgplz-f'),rg_ort:g('rg-rgort-f') + }; + if(!payload.name||!payload.benutzername||!payload.passwort||!payload.rg_name||!payload.rg_strasse||!payload.rg_plz||!payload.rg_ort){ + if(err)err.textContent='Bitte alle Pflichtfelder (*) ausfüllen'; + return; + } + const r=await fetch('/api/auth/registrieren',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(x=>x.json()).catch(()=>({error:'Verbindungsfehler'})); + if(r.ok){ + await initApp(); + refreshBadge(); + go('dashboard'); + } else { + if(err)err.textContent=r.error||'Registrierung fehlgeschlagen'; + } +} async function doLogout(){ await fetch('/api/auth/logout',{method:'POST'}); curUser=null; @@ -1240,6 +1328,20 @@ async function aboJetztBezahlen(){ toast(r.error||'Zahlung folgt in Kürze',false); } +// ── Rechtstexte (Impressum / Datenschutz / Nutzungsbedingungen) ────────── +const RECHTSTEXT_TITEL={impressum:'Impressum',datenschutz:'Datenschutzerklärung',agb:'Nutzungsbedingungen'}; +async function pgRechtstext(typ){ + const titel=RECHTSTEXT_TITEL[typ]||'Rechtstext'; + if(!Object.keys(_einstellungen).length){ + try{const s=await api('/api/einstellungen/public');_einstellungen={..._einstellungen,...s};}catch(e){} + } + const text=_einstellungen['rechtstext_'+typ]; + R('
'+ + (text?'
'+escH(text)+'
' + :'

Noch kein Text hinterlegt.

')+ + '
',titel); +} + // ── Update-Hinweis / Changelog ──────────────────────────── let clEntriesCache=[]; function clEntriesHtml(eintraege,adminMode){ @@ -1348,17 +1450,17 @@ async function pgBackend(){ '
'+ '
'+ '
'+ - // KI + // Rechtstexte '
'+ - '
KI – Claude API'+ - (s.claude_api_token_set?' Aktiv':'Nicht konfiguriert')+'
'+ - '

API-Token für zukünftige KI-Funktionen (Widerspruch-Vorschläge, Zusammenfassungen). Format: sk-ant-…

'+ - '
'+ - '
'+ - '
'+ - '
Token unter console.anthropic.com erstellen
'+ + '
Rechtstexte
'+ + '

Wird Kunden per 1-Klick-Link unten in der Sidebar angezeigt. Einfacher Text, Zeilenumbrüche werden übernommen.

'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ '
'+ - '
'+ + '
'+ ''+ ''+ @@ -1468,7 +1570,9 @@ async function pgAdminDash(){ ]); renderAdminDash(nutzer,log,patienten); } +let adminNutzerCache=[]; function renderAdminDash(nutzer,log,patienten){ + adminNutzerCache=nutzer||[]; const rows=(nutzer||[]).map(n=>{ const gesperrt=!n.aktiv; const isSelf=n.id===curUser?.id; @@ -1482,13 +1586,17 @@ function renderAdminDash(nutzer,log,patienten){ ''+(gesperrt?'Gesperrt':'Aktiv')+''+ ''+n.patienten_anzahl+''+ ''+aboText+''+ + '
'+fdate(n.erstellt_am)+'
'+ + '
'+(n.letzter_login?fdate(n.letzter_login):'–')+'
'+(n.letzte_ip||'')+'
'+ ''+(isSelf?'Eigenes Konto': '
'+ + ''+ (gesperrt? '': '' )+ (n.rolle!=='admin'?'':'')+ + (n.rolle!=='admin'?'':'')+ ''+ '
')+ ''; @@ -1508,7 +1616,7 @@ function renderAdminDash(nutzer,log,patienten){ :'
Noch keine Einträge.
'; R('
'+ '
Konten
'+ - '
'+ + '
NameRolleStatusPatientenAboAktionen
'+ ''+rows+'
NameRolleStatusPatientenAboRegistriertLetzter LoginAktionen
'+ '
'+ '
'+ @@ -1572,6 +1680,98 @@ async function adminAboFreischalten(uid){ toast('Abo freigeschaltet bis '+fdate(r.abo_bis)); pgAdminDash(); } +function adminNutzerBearbeitenModal(uid){ + const n=adminNutzerCache.find(x=>x.id===uid); + if(!n)return; + OM('Konto bearbeiten – '+escH(n.name||n.benutzername), + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
Rechnungsanschrift
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + ''+ + '
' + ); +} +async function adminNutzerBearbeitenSpeichern(uid){ + const g=id=>document.getElementById(id)?.value||''; + const r=await api('/api/nutzer/'+uid,{method:'PUT',body:JSON.stringify({ + name:g('ab-name'),rolle:g('ab-rolle'),aktiv:g('ab-aktiv'),passwort:g('ab-pw'), + rg_name:g('ab-rg-name'),rg_strasse:g('ab-rg-strasse'),rg_plz:g('ab-rg-plz'),rg_ort:g('ab-rg-ort') + })}); + if(r.ok){CM();toast('Konto aktualisiert ✓');pgAdminDash();} + else toast(r.error||'Fehler',false); +} + +// ── Rechnungserstellung (PDF, client-seitig wie Musterbrief/Widerspruch) ─── +function rechnungModal(uid){ + const n=adminNutzerCache.find(x=>x.id===uid); + if(!n)return; + const heute=new Date(); + const nr='RE-'+heute.getFullYear()+String(heute.getMonth()+1).padStart(2,'0')+'-'+uid; + const zeitraum=heute.toLocaleDateString('de-DE',{month:'long',year:'numeric'}); + OM('Rechnung erstellen – '+escH(n.name||n.benutzername), + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + (n.rg_name?'':'

Keine Rechnungsanschrift hinterlegt – bitte vor dem Erstellen ergänzen.

')+ + '
'+ + ''+ + '
' + ); +} +function rechnungErstellen(){ + const g=id=>document.getElementById(id)?.value||''; + const nr=g('rg-nr'),zeitraum=g('rg-zeitraum'),betrag=g('rg-betrag'),datum=g('rg-datum'); + const eName=g('rg-name'),eStr=g('rg-strasse'),ePlz=g('rg-plz'),eOrt=g('rg-ort'); + if(!eName||!eStr||!ePlz||!eOrt){toast('Bitte Rechnungsanschrift vollständig ausfüllen',false);return;} + const firma=_einstellungen.firmenname||'Pflegepilot'; + const absender=[firma,_einstellungen.firmen_strasse,(_einstellungen.firmen_plz||'')+' '+(_einstellungen.firmen_ort||''),_einstellungen.firmen_tel?('Tel.: '+_einstellungen.firmen_tel):''].filter(Boolean).join('\n'); + const text= +absender+'\n\n\n'+ +eName+'\n'+eStr+'\n'+ePlz+' '+eOrt+'\n\n\n'+ +'Rechnungsnummer: '+nr+'\n'+ +'Rechnungsdatum: '+fdate(datum)+'\n\n'+ +'Rechnung für Pflegepilot-Nutzung – '+zeitraum+'\n'+ +'───────────────────────────────────────────\n'+ +'Pos. Beschreibung Betrag\n'+ +'1 Pflegepilot Monatsabo, '+zeitraum+' '+betrag+' EUR\n'+ +'───────────────────────────────────────────\n'+ +'Gesamtbetrag: '+betrag+' EUR\n\n\n'+ +'Vielen Dank für Ihr Vertrauen.\n\n'+ +'Mit freundlichen Grüßen\n'+firma; + const el=document.createElement('div'); + el.style.cssText='font-family:"Courier New",Courier,monospace;font-size:10.5pt;line-height:1.7;padding:0;white-space:pre-wrap;color:#111;background:#fff;word-break:break-word;'; + el.textContent=text; + const fname='Rechnung_'+toFilename(nr)+'.pdf'; + html2pdf().set({margin:[25,20,28,25],filename:fname,html2canvas:{scale:2,useCORS:true},jsPDF:{unit:'mm',format:'a4',orientation:'portrait'}}) + .from(el).toPdf().get('pdf').then(pdf=>{ + addPageNums(pdf); + const blob=pdf.output('blob'); + const url=URL.createObjectURL(blob); + const a=document.createElement('a');a.href=url;a.download=fname; + document.body.appendChild(a);a.click();document.body.removeChild(a); + setTimeout(()=>URL.revokeObjectURL(url),2000); + toast('Rechnung heruntergeladen ✓'); + CM(); + }); +} async function dsgvoExport(){ const pid=document.getElementById('dsgvo-pid')?.value; const grund=(document.getElementById('dsgvo-grund')?.value||'').trim(); @@ -2462,6 +2662,7 @@ async function sendMailModal(typ){ } async function sendMail(typ){ + if(_schreibgesperrt())return; const to=(document.getElementById('ml-to')?.value||'').trim(); const subj=document.getElementById('ml-subj')?.value||''; const body=document.getElementById('ml-body')?.value||''; @@ -2510,6 +2711,7 @@ async function sendMail(typ){ } function dlW(){ + if(_schreibgesperrt())return; const t=document.getElementById('wpreview')?.textContent||''; if(!t.trim()){toast('Kein Text zum Exportieren',false);return;} const el=document.createElement('div'); @@ -2616,6 +2818,7 @@ function updM(){ } function cpM(){navigator.clipboard.writeText(document.getElementById('mpreview')?.textContent||'');toast('Kopiert');} function dlM(){ + if(_schreibgesperrt())return; const t=document.getElementById('mpreview')?.textContent||''; if(!t.trim()){toast('Kein Text zum Exportieren',false);return;} const el=document.createElement('div'); @@ -2857,13 +3060,15 @@ function newAM(){ '
'); } async function saveAufg(){ + if(_schreibgesperrt())return; const g=id=>document.getElementById(id)?.value||''; if(!g('nat')){toast('Titel eingeben',false);return;} - await api('/api/aufgaben',{method:'POST',body:JSON.stringify({titel:g('nat'),beschreibung:g('nad'),prioritaet:g('nap'),faellig_am:g('naf')})}); + const r=await api('/api/aufgaben',{method:'POST',body:JSON.stringify({titel:g('nat'),beschreibung:g('nad'),prioritaet:g('nap'),faellig_am:g('naf')})}); + if(r.error){toast(r.error,false);return;} CM();toast('Aufgabe angelegt');refreshBadge();if(curPage==='aufgaben')pgAufgaben(); } -initApp().then(()=>{if(curUser){refreshBadge();go('dashboard');}}); +initApp().then(()=>{if(curUser){refreshBadge();go(curUser.rolle==='admin'?'admindash':'dashboard');}});