This commit is contained in:
2026-04-21 10:00:36 +02:00
parent dcce7107ab
commit de8a7284d0
16 changed files with 1772 additions and 485 deletions

View File

@@ -34,7 +34,7 @@ from aza_style import (
_MODULE_DESCRIPTIONS = { _MODULE_DESCRIPTIONS = {
"ki": "Medizinische Fragen stellen,\nBefunde besprechen, Zweitmeinung einholen", "ki": "Medizinische Fragen stellen,\nBefunde besprechen, Zweitmeinung einholen",
"kg": "Diktat aufnehmen, transkribieren\nund Krankengeschichte erstellen", "kg": "Diktat aufnehmen und in Krankengeschichte umwandeln",
"empfang": "Empfangs-Chat, Aufgaben\nund Praxis-Kommunikation", "empfang": "Empfangs-Chat, Aufgaben\nund Praxis-Kommunikation",
"notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag", "notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag",
"translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen", "translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen",
@@ -69,6 +69,7 @@ def _draw_module_icon(c: tk.Canvas, key: str):
fill=fg, outline="") fill=fg, outline="")
elif key == "kg": elif key == "kg":
# Nur Fallback wenn logo.png fehlt (Kachel nutzt sonst echtes Logo als PhotoImage)
c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg) c.create_text(m, m, text="AzA", font=("Segoe UI", 11, "bold"), fill=fg)
elif key == "notizen": elif key == "notizen":
@@ -174,6 +175,7 @@ class AzaLauncher(tk.Tk):
self.attributes("-topmost", True) self.attributes("-topmost", True)
self._logo_img = None self._logo_img = None
self._kg_tile_icon = None # gleiches logo.png wie Header, 38×38 für AzA-Office-Kachel
try: try:
import sys as _sys import sys as _sys
_search = [] _search = []
@@ -190,8 +192,17 @@ class AzaLauncher(tk.Tk):
break break
if logo_path: if logo_path:
from PIL import Image, ImageTk from PIL import Image, ImageTk
img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS) _pil = Image.open(logo_path)
self._logo_img = ImageTk.PhotoImage(img, master=self) if _pil.mode not in ("RGB", "RGBA"):
_pil = _pil.convert("RGBA")
self._logo_img = ImageTk.PhotoImage(
_pil.resize((82, 82), Image.Resampling.LANCZOS),
master=self,
)
self._kg_tile_icon = ImageTk.PhotoImage(
_pil.resize((_ICON_SZ, _ICON_SZ), Image.Resampling.LANCZOS),
master=self,
)
except Exception: except Exception:
pass pass
@@ -248,12 +259,12 @@ class AzaLauncher(tk.Tk):
title_block = tk.Frame(title_row, bg=BG) title_block = tk.Frame(title_row, bg=BG)
title_block.pack(side="left", anchor="w") title_block.pack(side="left", anchor="w")
aza_lbl = tk.Label(title_block, text="AzA", aza_lbl = tk.Label(title_block, text="AzA",
font=(FONT_FAMILY, 24, "bold"), fg=ACCENT, bg=BG, font=(FONT_FAMILY, 19, "bold"), fg="#1a4d6d", bg=BG,
cursor="hand2") cursor="hand2")
aza_lbl.pack(anchor="w") aza_lbl.pack(anchor="w")
aza_lbl.bind("<Double-Button-1>", self._open_admin) aza_lbl.bind("<Double-Button-1>", self._open_admin)
tk.Label(title_block, text="Medizinischer KI-Arbeitsplatz", tk.Label(title_block, text="von Arzt zu Arzt",
font=(FONT_FAMILY, 10), fg=SUBTLE, bg=BG font=(FONT_FAMILY, 11), fg="#1a4d6d", bg=BG
).pack(anchor="w") ).pack(anchor="w")
self._build_capacity_bar(header) self._build_capacity_bar(header)
@@ -528,14 +539,15 @@ class AzaLauncher(tk.Tk):
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor) top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
top_row.pack(fill="x", pady=(0, 10)) top_row.pack(fill="x", pady=(0, 10))
if mod_key == "kg" and self._logo_img: icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor) bg=icon_color, highlightthickness=0, cursor=_cursor)
icon_lbl.image = self._logo_img icon_cv.pack(side="left")
icon_lbl.pack(side="left") if mod_key == "kg" and getattr(self, "_kg_tile_icon", None) is not None:
icon_cv.create_image(
_ICON_SZ // 2, _ICON_SZ // 2,
image=self._kg_tile_icon,
)
else: else:
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
bg=icon_color, highlightthickness=0, cursor=_cursor)
icon_cv.pack(side="left")
_draw_module_icon(icon_cv, mod_key) _draw_module_icon(icon_cv, mod_key)
lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"), lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),

View File

@@ -1601,6 +1601,19 @@ def telemetry_ping(data: TelemetryPing, request: Request):
return {"status": "ok"} return {"status": "ok"}
def _sync_empfang_practice_record(practice_id: str, admin_email: str, display_name: str) -> None:
"""Legt die Praxis in empfang_practices.json an (idempotent)."""
try:
from empfang_routes import _ensure_practice
except Exception as exc:
print(f"[LICENSE] empfang_routes import failed: {exc}")
return
try:
_ensure_practice(practice_id, name=display_name, admin_email=admin_email or "")
except Exception as exc:
print(f"[LICENSE] _ensure_practice failed: {exc}")
@app.get("/admin/telemetry/stats") @app.get("/admin/telemetry/stats")
def telemetry_stats(): def telemetry_stats():
uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds()) uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds())
@@ -1664,6 +1677,7 @@ def license_status(
status = None status = None
current_period_end = None current_period_end = None
customer_email = None customer_email = None
license_practice_id: Optional[str] = None
try: try:
try: try:
import stripe_routes # type: ignore import stripe_routes # type: ignore
@@ -1677,7 +1691,7 @@ def license_status(
if license_key and license_key.strip(): if license_key and license_key.strip():
row = con.execute( row = con.execute(
""" """
SELECT status, current_period_end, customer_email SELECT status, current_period_end, customer_email, practice_id
FROM licenses FROM licenses
WHERE upper(license_key) = ? WHERE upper(license_key) = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -1688,7 +1702,7 @@ def license_status(
if row is None and email and email.strip(): if row is None and email and email.strip():
row = con.execute( row = con.execute(
""" """
SELECT status, current_period_end, customer_email SELECT status, current_period_end, customer_email, practice_id
FROM licenses FROM licenses
WHERE lower(customer_email) = ? WHERE lower(customer_email) = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -1697,29 +1711,17 @@ def license_status(
(email.strip().lower(),), (email.strip().lower(),),
).fetchone() ).fetchone()
if row is None: if row is None:
n_active = con.execute( print("[LICENSE-STATUS] keine eindeutige Lizenzzeile (license_key oder customer_email erforderlich)")
"SELECT COUNT(*) FROM licenses WHERE status = 'active'"
).fetchone()[0]
if n_active == 1:
row = con.execute(
"""
SELECT status, current_period_end, customer_email
FROM licenses
WHERE status = 'active'
LIMIT 1
"""
).fetchone()
print(f"[LICENSE-STATUS] fallback: single active license -> {row[2] if row else 'none'}")
else:
print(f"[LICENSE-STATUS] fallback: {n_active} active licenses, no auto-select")
if row: if row:
status = row[0] status = row[0]
current_period_end = int(row[1]) if row[1] is not None else None current_period_end = int(row[1]) if row[1] is not None else None
customer_email = str(row[2]).strip() if row[2] is not None else None customer_email = str(row[2]).strip() if row[2] is not None else None
license_practice_id = str(row[3]).strip() if len(row) > 3 and row[3] else None
except Exception: except Exception:
status = None status = None
current_period_end = None current_period_end = None
customer_email = None customer_email = None
license_practice_id = None
decision = compute_license_decision(current_period_end=current_period_end, status=status) decision = compute_license_decision(current_period_end=current_period_end, status=status)
@@ -1736,11 +1738,14 @@ def license_status(
"used_devices": 0, "used_devices": 0,
"device_allowed": True, "device_allowed": True,
"reason": "ok", "reason": "ok",
"practice_id": license_practice_id or None,
} }
dev_user_key = license_practice_id.strip() if license_practice_id and license_practice_id.strip() else "default"
if device_id and customer_email: if device_id and customer_email:
dd = enforce_and_touch_device( dd = enforce_and_touch_device(
customer_email=customer_email, user_key="default", customer_email=customer_email, user_key=dev_user_key,
device_id=device_id, db_path=str(db_path), device_id=device_id, db_path=str(db_path),
device_name=device_name, app_version=app_version, device_name=device_name, app_version=app_version,
device_fingerprint=device_fingerprint, device_fingerprint=device_fingerprint,
@@ -1784,7 +1789,7 @@ def license_activate(
row = con.execute( row = con.execute(
""" """
SELECT subscription_id, status, current_period_end, customer_email, SELECT subscription_id, status, current_period_end, customer_email,
allowed_users, devices_per_user allowed_users, devices_per_user, practice_id
FROM licenses FROM licenses
WHERE upper(license_key) = ? WHERE upper(license_key) = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
@@ -1796,9 +1801,30 @@ def license_activate(
if not row: if not row:
raise HTTPException(status_code=404, detail="Lizenzschluessel ungueltig.") raise HTTPException(status_code=404, detail="Lizenzschluessel ungueltig.")
sub_id, status, cpe, cust_email, au, dpu = row sub_id, status, cpe, cust_email, au, dpu = row[:6]
practice_id_raw = row[6] if len(row) > 6 else None
current_period_end = int(cpe) if cpe is not None else None current_period_end = int(cpe) if cpe is not None else None
practice_id = (str(practice_id_raw).strip() if practice_id_raw else "") or ""
if not practice_id:
practice_id = f"prac_{uuid.uuid4().hex[:12]}"
try:
with sqlite3.connect(db_path) as con:
con.execute(
"""
UPDATE licenses
SET practice_id = ?, updated_at = ?
WHERE subscription_id = ?
""",
(practice_id, int(time.time()), sub_id),
)
con.commit()
except Exception as exc:
print(f"[LICENSE] practice_id update failed: {exc}")
display_name = (str(cust_email).strip() if cust_email else "") or "Meine Praxis"
_sync_empfang_practice_record(practice_id, str(cust_email or "").strip(), display_name)
decision = compute_license_decision(current_period_end=current_period_end, status=status) decision = compute_license_decision(current_period_end=current_period_end, status=status)
device_id = request.headers.get("X-Device-Id") device_id = request.headers.get("X-Device-Id")
@@ -1823,11 +1849,14 @@ def license_activate(
"used_devices": 0, "used_devices": 0,
"device_allowed": True, "device_allowed": True,
"reason": "ok", "reason": "ok",
"practice_id": practice_id,
} }
dev_user_key = practice_id.strip() if practice_id.strip() else "default"
if cust_email: if cust_email:
dd = enforce_and_touch_device( dd = enforce_and_touch_device(
customer_email=cust_email, user_key="default", customer_email=cust_email, user_key=dev_user_key,
device_id=device_id, db_path=str(db_path), device_id=device_id, db_path=str(db_path),
device_name=device_name, app_version=app_version, device_name=device_name, app_version=app_version,
device_fingerprint=device_fingerprint, device_fingerprint=device_fingerprint,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"x": 1181,
"y": 224,
"width": 480,
"height": 820,
"on_top": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -48,10 +48,20 @@
"dermatology" "dermatology"
], ],
"ui_font_delta": 0, "ui_font_delta": 0,
"global_right_click_paste": true, "global_right_click_paste": false,
"todo_auto_open": false, "todo_auto_open": false,
"autocopy_after_diktat": true, "autocopy_after_diktat": true,
"kommentare_auto_open": true, "kommentare_auto_open": true,
"empfang_auto_open": false,
"empfang_was_open": false,
"empfang_prefs": {
"show_patient": false,
"show_ther": false,
"show_proc": false,
"show_kom": false,
"last_patient": "",
"geometry": "776x779+1604+284"
},
"medikament_quelle": "compendium.ch", "medikament_quelle": "compendium.ch",
"diagnose_quelle": "", "diagnose_quelle": "",
"dokumente_collapsed": false, "dokumente_collapsed": false,

View File

@@ -1,4 +1,7 @@
{ {
"⏺ Start": 33, "⏺ Start": 23,
"Übersetzen": 1 "🔑": 2,
"👤": 6,
"⏺ Aufnahme starten": 5,
"Diktat": 1
} }

View File

@@ -1 +1 @@
420x380+1889+452 420x380+1744+1053

View File

@@ -1 +1 @@
908x1041+1071+79 908x1041+1564+540

View File

@@ -1 +1 @@
{"used": 521654, "total": 1000000, "budget_dollars": 0, "used_dollars": 0} {"used": 522567, "total": 1000000, "budget_dollars": 0, "used_dollars": 0}

View File

@@ -1,5 +1,10 @@
{ {
"name": "André M. Surovy", "name": "André M. Surovy",
"specialty": "Dermatologie", "specialty": "Dermatologie",
"clinic": "Praxis Lindegut AG" "clinic": "Praxis Lindegut AG",
"code": "",
"email": "andre.surovy@haut-winterthur.ch",
"password_hash": "$2b$12$VdOUv97Tzk7ccr2HsU632.5L5waHju1YDmw6oJnRkTWcpp/E6lBbW",
"license_key": "AZA-6TY3-63AU-W9ZR-ZO7D",
"practice_id": "prac_189854535b85"
} }

View File

@@ -1 +1 @@
1461 1205 1189 477 638 340 1461 1205 1189 477 717 404

View File

@@ -1 +1 @@
{"valid": true, "valid_until": 1777652509, "cached_at": 1775118734.4902344} {"valid": true, "valid_until": 1776783700, "cached_at": 1776697300.917711}

View File

@@ -1,4 +1,4 @@
{ {
"transcript_vertical": 340, "transcript_vertical": 404,
"kg_vertical": 329 "kg_vertical": 535
} }

View File

@@ -21,6 +21,7 @@ import smtplib
import sqlite3 import sqlite3
import string import string
import time import time
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@@ -115,6 +116,8 @@ def _ensure_storage() -> None:
con.execute("ALTER TABLE licenses ADD COLUMN current_period_end INTEGER") con.execute("ALTER TABLE licenses ADD COLUMN current_period_end INTEGER")
if "license_key" not in cols: if "license_key" not in cols:
con.execute("ALTER TABLE licenses ADD COLUMN license_key TEXT") con.execute("ALTER TABLE licenses ADD COLUMN license_key TEXT")
if "practice_id" not in cols:
con.execute("ALTER TABLE licenses ADD COLUMN practice_id TEXT")
con.commit() con.commit()
@@ -409,6 +412,59 @@ def _log_event(kind: str, payload: Dict[str, Any]) -> None:
f.write(json.dumps(rec, ensure_ascii=False, default=_decimal_default) + "\n") f.write(json.dumps(rec, ensure_ascii=False, default=_decimal_default) + "\n")
def _new_practice_id() -> str:
"""Gleiches Format wie empfang_routes._generate_practice_id (Mandanten-ID)."""
return f"prac_{uuid.uuid4().hex[:12]}"
def _sync_empfang_practice_from_license(
practice_id: str,
customer_email: Optional[str],
display_name: str,
) -> None:
"""Empfang-Praxisdatei mit SQLite-Lizenz synchronisieren (eine Wahrheit)."""
pid = (practice_id or "").strip()
if not pid:
return
try:
from empfang_routes import _ensure_practice
except Exception as exc:
print(f"[STRIPE] empfang_routes Import: {exc}")
return
try:
em = (customer_email or "").strip()
name = (display_name or "").strip() or (em.split("@")[0] if "@" in em else "Meine Praxis")
_ensure_practice(pid, name=name, admin_email=em)
except Exception as exc:
print(f"[STRIPE] _ensure_practice: {exc}")
def lookup_practice_id_for_license_email(email: str) -> Optional[str]:
"""Liefert die serverseitig gespeicherte practice_id zur Kunden-E-Mail (Lizenz ↔ Praxis)."""
_ensure_storage()
e = (email or "").strip().lower()
if not e:
return None
try:
with sqlite3.connect(DB_PATH) as con:
row = con.execute(
"""
SELECT practice_id FROM licenses
WHERE lower(customer_email) = ?
AND practice_id IS NOT NULL
AND trim(practice_id) != ''
ORDER BY updated_at DESC
LIMIT 1
""",
(e,),
).fetchone()
if not row or not row[0]:
return None
return str(row[0]).strip()
except Exception:
return None
def _upsert_license( def _upsert_license(
*, *,
subscription_id: str, subscription_id: str,
@@ -422,27 +478,36 @@ def _upsert_license(
current_period_end: Optional[int], current_period_end: Optional[int],
license_key: Optional[str] = None, license_key: Optional[str] = None,
) -> str: ) -> str:
"""Upsert license row. Returns the license_key (generated if not yet set).""" """Upsert license row. Returns the license_key (generated if not yet set).
Jede Lizenzzeile erhält spätestens hier eine stabile practice_id (Mandant),
damit Stripe-Webhook und Desktop/Empfang dieselbe ID nutzen.
"""
now = int(time.time()) now = int(time.time())
with sqlite3.connect(DB_PATH) as con: with sqlite3.connect(DB_PATH) as con:
existing_key = None existing_key = None
existing_pid = ""
row = con.execute( row = con.execute(
"SELECT license_key FROM licenses WHERE subscription_id = ?", "SELECT license_key, practice_id FROM licenses WHERE subscription_id = ?",
(subscription_id,), (subscription_id,),
).fetchone() ).fetchone()
if row and row[0]: if row:
existing_key = row[0] if row[0]:
existing_key = row[0]
if row[1]:
existing_pid = str(row[1]).strip()
final_key = existing_key or license_key or _generate_license_key() final_key = existing_key or license_key or _generate_license_key()
final_pid = existing_pid or _new_practice_id()
con.execute( con.execute(
""" """
INSERT INTO licenses( INSERT INTO licenses(
subscription_id, customer_id, status, lookup_key, subscription_id, customer_id, status, lookup_key,
allowed_users, devices_per_user, customer_email, client_reference_id, allowed_users, devices_per_user, customer_email, client_reference_id,
current_period_end, updated_at, license_key current_period_end, updated_at, license_key, practice_id
) )
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(subscription_id) DO UPDATE SET ON CONFLICT(subscription_id) DO UPDATE SET
customer_id=excluded.customer_id, customer_id=excluded.customer_id,
status=excluded.status, status=excluded.status,
@@ -453,7 +518,12 @@ def _upsert_license(
client_reference_id=COALESCE(excluded.client_reference_id, client_reference_id), client_reference_id=COALESCE(excluded.client_reference_id, client_reference_id),
current_period_end=COALESCE(excluded.current_period_end, current_period_end), current_period_end=COALESCE(excluded.current_period_end, current_period_end),
updated_at=excluded.updated_at, updated_at=excluded.updated_at,
license_key=COALESCE(license_key, excluded.license_key) license_key=COALESCE(license_key, excluded.license_key),
practice_id=CASE
WHEN licenses.practice_id IS NOT NULL AND trim(licenses.practice_id) != ''
THEN licenses.practice_id
ELSE excluded.practice_id
END
""", """,
( (
subscription_id, subscription_id,
@@ -467,9 +537,21 @@ def _upsert_license(
current_period_end, current_period_end,
now, now,
final_key, final_key,
final_pid,
), ),
) )
con.commit() con.commit()
row2 = con.execute(
"SELECT practice_id, customer_email FROM licenses WHERE subscription_id = ?",
(subscription_id,),
).fetchone()
if row2:
pid_s = str(row2[0]).strip() if row2[0] else ""
em = (str(row2[1]).strip() if row2[1] else "") or (customer_email or "").strip()
if pid_s:
disp = em.split("@")[0] if "@" in em else "Meine Praxis"
_sync_empfang_practice_from_license(pid_s, em or None, disp)
return final_key return final_key

View File

@@ -183,6 +183,19 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
.login-switch{text-align:center;margin-top:14px;font-size:.82rem;color:#6a8a9a} .login-switch{text-align:center;margin-top:14px;font-size:.82rem;color:#6a8a9a}
.login-switch a{color:#5B8DB3;cursor:pointer;text-decoration:underline} .login-switch a{color:#5B8DB3;cursor:pointer;text-decoration:underline}
/* Registrierung: Überschrift, Fließtext, Labels, Felder, Button, Link alles 9pt */
.login-box.login-register,
.login-box.login-register h2,
.login-box.login-register p,
.login-box.login-register label,
.login-box.login-register input,
.login-box.login-register select,
.login-box.login-register button,
.login-box.login-register .login-error,
.login-box.login-register .login-switch,
.login-box.login-register .login-switch a{font-size:9pt!important}
.login-box.login-register h2{font-weight:600}
/* === Admin Panel === */ /* === Admin Panel === */
.admin-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.3);z-index:150;align-items:flex-start;justify-content:center;padding-top:40px;overflow-y:auto} .admin-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.3);z-index:150;align-items:flex-start;justify-content:center;padding-top:40px;overflow-y:auto}
.admin-overlay.open{display:flex} .admin-overlay.open{display:flex}
@@ -386,7 +399,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
<div class="status-bar"> <div class="status-bar">
<span>Aktualisiert alle 10 Sek.</span> <span>Aktualisiert alle 10 Sek.</span>
<span style="opacity:.5" id="ui-version">v2026.04.18b</span> <span style="opacity:.5" id="ui-version">v2026.04.20</span>
</div> </div>
<script> <script>
@@ -394,6 +407,9 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
STATE STATE
=================================================================== */ =================================================================== */
var API_BASE = window.location.origin + '/empfang'; var API_BASE = window.location.origin + '/empfang';
function getPracticeIdOrEmpty() {
try { return localStorage.getItem('aza_practice_id') || ''; } catch (e) { return ''; }
}
var currentSession = null; var currentSession = null;
var practiceUsers = []; var practiceUsers = [];
var serverTasks = []; var serverTasks = [];
@@ -443,7 +459,9 @@ var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 1
API WRAPPER (global 401 handling) API WRAPPER (global 401 handling)
=================================================================== */ =================================================================== */
async function apiFetch(url, opts) { async function apiFetch(url, opts) {
var r = await fetch(url, opts || {}); opts = opts || {};
if (!opts.credentials) opts.credentials = 'include';
var r = await fetch(url, opts);
if (r.status === 401) { if (r.status === 401) {
currentSession = null; currentSession = null;
stopPolling(); stopPolling();
@@ -458,7 +476,7 @@ async function apiFetch(url, opts) {
=================================================================== */ =================================================================== */
async function checkAuth() { async function checkAuth() {
try { try {
var r = await fetch(API_BASE + '/auth/me'); var r = await fetch(API_BASE + '/auth/me', {credentials: 'include'});
if (r.status === 401) return null; if (r.status === 401) return null;
var d = await r.json(); var d = await r.json();
if (d.authenticated) return d; if (d.authenticated) return d;
@@ -471,7 +489,7 @@ async function showLoginOverlay() {
var overlay = document.getElementById('login-overlay'); var overlay = document.getElementById('login-overlay');
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
try { try {
var r = await fetch(API_BASE + '/auth/needs_setup'); var r = await fetch(API_BASE + '/auth/needs_setup', {credentials: 'include'});
var d = await r.json(); var d = await r.json();
if (d.needs_setup) { renderSetupForm(); return; } if (d.needs_setup) { renderSetupForm(); return; }
} catch(e) {} } catch(e) {}
@@ -484,6 +502,7 @@ function hideLoginOverlay() {
function renderSetupForm() { function renderSetupForm() {
var box = document.getElementById('login-box'); var box = document.getElementById('login-box');
box.className = 'login-box';
box.innerHTML = box.innerHTML =
'<h2>AZA Praxis-Chat einrichten</h2>' + '<h2>AZA Praxis-Chat einrichten</h2>' +
'<p>Willkommen! Richten Sie Ihre Praxis und den ersten Administrator ein.</p>' + '<p>Willkommen! Richten Sie Ihre Praxis und den ersten Administrator ein.</p>' +
@@ -498,6 +517,7 @@ function renderSetupForm() {
function renderLoginForm() { function renderLoginForm() {
var box = document.getElementById('login-box'); var box = document.getElementById('login-box');
box.className = 'login-box';
var lastUser = localStorage.getItem('aza_last_login_user') || ''; var lastUser = localStorage.getItem('aza_last_login_user') || '';
box.innerHTML = box.innerHTML =
'<h2>Anmelden</h2>' + '<h2>Anmelden</h2>' +
@@ -520,39 +540,159 @@ function renderLoginForm() {
function renderForgotPasswordForm() { function renderForgotPasswordForm() {
var box = document.getElementById('login-box'); var box = document.getElementById('login-box');
box.className = 'login-box';
var pid = getPracticeIdOrEmpty();
box.innerHTML = box.innerHTML =
'<h2>Passwort vergessen</h2>' + '<h2>Passwort vergessen</h2>' +
'<p>Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zur\u00fccksetzen.</p>' + '<p>Geben Sie Ihren <strong>Benutzernamen</strong> (wie in der Praxis angezeigt) oder Ihre <strong>E-Mail-Adresse</strong> ein.</p>' +
'<div class="login-field"><label>E-Mail</label><input type="email" id="forgot-email" autocomplete="email" placeholder="praxis@beispiel.ch"></div>' + '<p style="font-size:.82rem;color:#6a8a9a">Nach einer erfolgreichen Anmeldung speichert der Browser die Praxiszuordnung &mdash; dann ist der Ablauf einfacher.</p>' +
(pid ? '' : '<p style="font-size:.78rem;color:#a67c00">Hinweis: Auf diesem Ger\u00e4t ist noch keine Praxis gespeichert. Benutzername oder E-Mail trotzdem m\u00f6glich; bei mehreren Konten ggf. zus\u00e4tzliche Auswahl.</p>') +
'<div class="login-field"><label>Benutzername oder E-Mail</label><input type="text" id="forgot-login" autocomplete="username" placeholder="z. B. Suro oder name@praxis.ch"></div>' +
'<button class="login-btn" onclick="doForgotPassword()">Reset-Link senden</button>' + '<button class="login-btn" onclick="doForgotPassword()">Reset-Link senden</button>' +
'<div class="login-error" id="login-error"></div>' + '<div class="login-error" id="login-error"></div>' +
'<div class="login-switch"><a onclick="renderLoginForm()">&larr; Zur\u00fcck zur Anmeldung</a></div>'; '<div class="login-switch"><a onclick="renderLoginForm()">&larr; Zur\u00fcck zur Anmeldung</a></div>';
document.getElementById('forgot-email').focus(); document.getElementById('forgot-login').focus();
}
function renderForgotPickUser(loginEmail, candidates) {
var box = document.getElementById('login-box');
box.className = 'login-box';
var buttons = (candidates || []).map(function(c, i) {
var lab = esc(c.display_name || '') + (c.practice_name ? ' \u2013 ' + esc(c.practice_name) : '');
return '<button type="button" class="login-btn" style="margin-bottom:8px;width:100%;text-align:left" onclick="doForgotPickCandidate(' + i + ')">' + lab + '</button>';
}).join('');
box.innerHTML =
'<h2>Passwort vergessen</h2>' +
'<p>Diese E-Mail-Adresse ist <strong>mehreren Benutzerkonten</strong> zugeordnet. Bitte w\u00e4hlen Sie Ihr Konto oder geben Sie Ihren Benutzernamen ein.</p>' +
'<div class="login-field" style="margin-top:10px">' + buttons + '</div>' +
'<div class="login-field"><label>Benutzername (exakt)</label><input type="text" id="forgot-pick-manual" autocomplete="username" placeholder="Wie in der Praxis angezeigt"></div>' +
'<button class="login-btn" onclick="doForgotPickManual()">Auswahl best\u00e4tigen</button>' +
'<div class="login-error" id="login-error"></div>' +
'<div class="login-switch"><a onclick="renderForgotPasswordForm()">&larr; Zur\u00fcck</a></div>';
window._forgotCandidates = candidates;
window._forgotLoginEmail = loginEmail;
document.getElementById('forgot-pick-manual').focus();
}
async function doForgotPickCandidate(i) {
var c = (window._forgotCandidates || [])[i];
if (!c) return;
await _postForgotSecondStep(window._forgotLoginEmail, c.display_name, c.practice_id);
}
async function doForgotPickManual() {
var v = (document.getElementById('forgot-pick-manual').value || '').trim();
var errEl = document.getElementById('login-error');
var cand = window._forgotCandidates || [];
var hit = null;
for (var j = 0; j < cand.length; j++) {
if ((cand[j].display_name || '') === v) { hit = cand[j]; break; }
}
if (!hit) {
errEl.style.color = '#842029';
errEl.textContent = 'Kein passender Benutzername. Bitte exakt wie in der Praxis w\u00e4hlen oder eingeben.';
return;
}
await _postForgotSecondStep(window._forgotLoginEmail, hit.display_name, hit.practice_id);
}
async function _postForgotSecondStep(loginEmail, displayName, practiceId) {
var errEl = document.getElementById('login-error');
errEl.style.color = '';
errEl.textContent = '';
try {
var r = await fetch(API_BASE + '/auth/forgot_password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
login: loginEmail,
display_name: displayName,
practice_id: practiceId || getPracticeIdOrEmpty()
})
});
var d = await r.json().catch(function() { return {}; });
if (d.step === 'no_email' || (d.success === false && d.step === 'no_email')) {
errEl.style.color = '#842029';
errEl.textContent = d.message || 'Keine E-Mail hinterlegt.';
return;
}
if (d.success === false && d.message) {
errEl.style.color = '#842029';
errEl.textContent = d.message;
return;
}
errEl.style.color = '#155724';
errEl.textContent = d.message || 'Link wurde gesendet.';
} catch (e) {
errEl.style.color = '#842029';
errEl.textContent = 'Fehler beim Senden.';
}
} }
async function doForgotPassword() { async function doForgotPassword() {
var email = (document.getElementById('forgot-email').value || '').trim(); var raw = (document.getElementById('forgot-login').value || '').trim();
var errEl = document.getElementById('login-error'); var errEl = document.getElementById('login-error');
if (!email) { errEl.textContent = 'Bitte E-Mail-Adresse eingeben.'; return; } errEl.style.color = '';
if (!raw) { errEl.style.color = '#842029'; errEl.textContent = 'Bitte Benutzername oder E-Mail eingeben.'; return; }
try { try {
var r = await fetch(API_BASE + '/auth/forgot_password', { var r = await fetch(API_BASE + '/auth/forgot_password', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST',
body: JSON.stringify({email: email}) headers: {'Content-Type': 'application/json'},
body: JSON.stringify({login: raw, practice_id: getPracticeIdOrEmpty()})
}); });
var d = await r.json(); var d = await r.json().catch(function() { return {}; });
if (d.step === 'pick_user' && d.candidates && d.candidates.length) {
renderForgotPickUser(d.login || raw, d.candidates);
return;
}
if (d.step === 'no_email' || (d.success === false && d.step === 'no_email')) {
errEl.style.color = '#842029';
errEl.textContent = d.message || 'Keine E-Mail hinterlegt.';
return;
}
if (d.success === false && d.message) {
errEl.style.color = '#842029';
errEl.textContent = d.message;
return;
}
errEl.style.color = '#155724'; errEl.style.color = '#155724';
errEl.textContent = d.message || 'Reset-Link wurde gesendet.'; errEl.textContent = d.message || 'Wenn ein passendes Konto existiert, wurde ein Link gesendet.';
} catch(e) { errEl.textContent = 'Fehler beim Senden.'; } } catch(e) {
errEl.style.color = '#842029';
errEl.textContent = 'Fehler beim Senden.';
}
}
function stripResetTokenFromUrl() {
try {
var p = window.location.pathname || '';
var sp = new URLSearchParams(window.location.search);
sp.delete('reset_token');
var q = sp.toString();
history.replaceState(null, '', p + (q ? '?' + q : ''));
} catch (e) {}
}
function renderResetPasswordInvalid(msg) {
stripResetTokenFromUrl();
var box = document.getElementById('login-box');
box.className = 'login-box';
box.innerHTML =
'<h2>Link ung\u00fcltig</h2>' +
'<p class="login-error" id="login-error" style="display:block;color:#842029">' + esc(msg || 'Ung\u00fcltiger oder abgelaufener Link.') + '</p>' +
'<div class="login-switch"><a onclick="renderLoginForm()">Zur Anmeldung</a></div>';
} }
function renderResetPasswordForm(resetToken) { function renderResetPasswordForm(resetToken) {
var box = document.getElementById('login-box'); var box = document.getElementById('login-box');
box.className = 'login-box';
box.innerHTML = box.innerHTML =
'<h2>Neues Passwort setzen</h2>' + '<h2>Passwort zur\u00fccksetzen</h2>' +
'<p>Sie haben eine Passwort-Zur\u00fccksetzung angefordert.</p>' +
'<p>Bitte w\u00e4hlen Sie ein neues Passwort (min. 4 Zeichen).</p>' + '<p>Bitte w\u00e4hlen Sie ein neues Passwort (min. 4 Zeichen).</p>' +
'<div class="login-field"><label>Neues Passwort</label><input type="password" id="reset-pass" autocomplete="new-password"></div>' + '<div class="login-field"><label>Neues Passwort</label><input type="password" id="reset-pass" autocomplete="new-password"></div>' +
'<div class="login-field"><label>Passwort best\u00e4tigen</label><input type="password" id="reset-pass2" autocomplete="new-password"></div>' + '<div class="login-field"><label>Neues Passwort best\u00e4tigen</label><input type="password" id="reset-pass2" autocomplete="new-password"></div>' +
'<button class="login-btn" onclick="doResetPassword(\'' + resetToken + '\')">Passwort speichern</button>' + '<button class="login-btn" onclick="doResetPassword(\'' + resetToken + '\')">Neues Passwort speichern</button>' +
'<div class="login-error" id="login-error"></div>'; '<div class="login-error" id="login-error"></div>';
document.getElementById('reset-pass').focus(); document.getElementById('reset-pass').focus();
} }
@@ -568,23 +708,34 @@ async function doResetPassword(resetToken) {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reset_token: resetToken, password: pass1}) body: JSON.stringify({reset_token: resetToken, password: pass1})
}); });
var d = await r.json(); var d = await r.json().catch(function() { return {}; });
if (r.ok && d.success) { if (r.ok && d.success) {
stripResetTokenFromUrl();
var pref = (d.display_name || d.email || '').trim();
if (pref) { try { localStorage.setItem('aza_last_login_user', pref); } catch (e2) {} }
errEl.style.color = '#155724'; errEl.style.color = '#155724';
errEl.textContent = 'Passwort ge\u00e4ndert. Sie k\u00f6nnen sich jetzt anmelden.'; errEl.textContent = 'Passwort wurde erfolgreich ge\u00e4ndert. Sie k\u00f6nnen sich jetzt anmelden.';
setTimeout(renderLoginForm, 2000); setTimeout(renderLoginForm, 2000);
} else { } else {
errEl.textContent = d.detail || d.message || 'Fehler beim Zur\u00fccksetzen.'; var fail = d.detail;
if (typeof fail === 'string') {
errEl.textContent = fail;
} else if (Array.isArray(fail) && fail[0] && fail[0].msg) {
errEl.textContent = fail.map(function(x) { return x.msg; }).join(' ');
} else {
errEl.textContent = d.message || 'Passwort konnte nicht zur\u00fcckgesetzt werden.';
}
} }
} catch(e) { errEl.textContent = 'Fehler beim Zur\u00fccksetzen.'; } } catch(e) { errEl.textContent = 'Passwort konnte nicht zur\u00fcckgesetzt werden.'; }
} }
function renderRegisterForm() { function renderRegisterForm() {
var box = document.getElementById('login-box'); var box = document.getElementById('login-box');
box.className = 'login-box login-register';
box.innerHTML = box.innerHTML =
'<h2>Bei Ihrer Praxis registrieren</h2>' + '<h2>registrieren Sie sich f\u00fcr den Chat</h2>' +
'<p>Ihr Administrator hat Ihnen einen Einladungscode gegeben.</p>' + '<p>Ihr Administrator hat Ihnen einen Einladungscode gegeben.</p>' +
'<div class="login-field"><label>Einladungscode</label><input type="text" id="reg-code" placeholder="z.B. Xk7m-9pQr"></div>' + '<div class="login-field"><label>Einladungscode</label><input type="text" id="reg-code" placeholder="z.B. CHAT-AB12-CD34"></div>' +
'<div class="login-field"><label>Ihr Name</label><input type="text" id="reg-name" autocomplete="username" placeholder="z.B. Sandra M\u00fcller"></div>' + '<div class="login-field"><label>Ihr Name</label><input type="text" id="reg-name" autocomplete="username" placeholder="z.B. Sandra M\u00fcller"></div>' +
'<div class="login-field"><label>Passwort (min. 4 Zeichen)</label><input type="password" id="reg-pass" autocomplete="new-password"></div>' + '<div class="login-field"><label>Passwort (min. 4 Zeichen)</label><input type="password" id="reg-pass" autocomplete="new-password"></div>' +
'<div class="login-field"><label>Ihre Rolle</label>' + '<div class="login-field"><label>Ihre Rolle</label>' +
@@ -625,10 +776,15 @@ async function doSetup() {
if (!pass || pass.length < 4) { errEl.textContent = 'Passwort (min. 4 Zeichen) erforderlich.'; return; } if (!pass || pass.length < 4) { errEl.textContent = 'Passwort (min. 4 Zeichen) erforderlich.'; return; }
try { try {
var r = await fetch(API_BASE + '/auth/setup', { var r = await fetch(API_BASE + '/auth/setup', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, password: pass, practice_name: practiceName, email: email}) body: JSON.stringify({name: name, password: pass, practice_name: practiceName, email: email})
}); });
if (r.ok) { if (r.ok) {
try {
var sd = await r.json();
if (sd.practice_id) localStorage.setItem('aza_practice_id', sd.practice_id);
} catch(e) {}
if (!await onAuthSuccess()) errEl.textContent = 'Einrichtung fehlgeschlagen.'; if (!await onAuthSuccess()) errEl.textContent = 'Einrichtung fehlgeschlagen.';
} else { } else {
var d = await r.json().catch(function(){ return {}; }); var d = await r.json().catch(function(){ return {}; });
@@ -641,20 +797,39 @@ async function doLogin() {
var name = (document.getElementById('login-name').value || '').trim(); var name = (document.getElementById('login-name').value || '').trim();
var pass = document.getElementById('login-pass').value || ''; var pass = document.getElementById('login-pass').value || '';
var errEl = document.getElementById('login-error'); var errEl = document.getElementById('login-error');
if (!name || !pass) { errEl.textContent = 'Name/E-Mail und Passwort erforderlich.'; return; } errEl.style.color = '';
if (!name || !pass) { errEl.textContent = 'Benutzername/E-Mail und Passwort erforderlich.'; return; }
try { try {
var r = await fetch(API_BASE + '/auth/login', { var r = await fetch(API_BASE + '/auth/login', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', credentials: 'include',
body: JSON.stringify({name: name, password: pass}) headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
password: pass,
practice_id: getPracticeIdOrEmpty()
})
}); });
if (r.ok) { if (r.ok) {
localStorage.setItem('aza_last_login_user', name); try {
var ld = await r.json();
if (ld.practice_id) localStorage.setItem('aza_practice_id', ld.practice_id);
var saveUser = (ld.display_name || name).trim();
localStorage.setItem('aza_last_login_user', saveUser);
} catch(e) {}
if (!await onAuthSuccess()) errEl.textContent = 'Anmeldung fehlgeschlagen.'; if (!await onAuthSuccess()) errEl.textContent = 'Anmeldung fehlgeschlagen.';
} else { } else {
var d = await r.json().catch(function(){ return {}; }); var d = await r.json().catch(function(){ return {}; });
errEl.textContent = d.detail || d.error || 'Anmeldung fehlgeschlagen.'; errEl.style.color = '#842029';
var det = d.detail;
if (typeof det === 'object' && det !== null && det.message) {
errEl.textContent = det.message;
} else if (typeof det === 'string') {
errEl.textContent = det;
} else {
errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.';
}
} }
} catch(e) { errEl.textContent = 'Verbindungsfehler.'; } } catch(e) { errEl.style.color = '#842029'; errEl.textContent = 'Verbindungsfehler.'; }
} }
async function doRegister() { async function doRegister() {
@@ -666,10 +841,15 @@ async function doRegister() {
if (!code || !name || !pass) { errEl.textContent = 'Alle Felder erforderlich.'; return; } if (!code || !name || !pass) { errEl.textContent = 'Alle Felder erforderlich.'; return; }
try { try {
var r = await fetch(API_BASE + '/auth/register', { var r = await fetch(API_BASE + '/auth/register', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({invite_code: code, name: name, password: pass, role: role}) body: JSON.stringify({invite_code: code, name: name, password: pass, role: role})
}); });
if (r.ok) { if (r.ok) {
try {
var rd = await r.json();
if (rd.practice_id) localStorage.setItem('aza_practice_id', rd.practice_id);
} catch(e) {}
if (!await onAuthSuccess()) errEl.textContent = 'Registrierung fehlgeschlagen.'; if (!await onAuthSuccess()) errEl.textContent = 'Registrierung fehlgeschlagen.';
} else { } else {
var d = await r.json().catch(function(){ return {}; }); var d = await r.json().catch(function(){ return {}; });
@@ -679,7 +859,7 @@ async function doRegister() {
} }
async function doLogout() { async function doLogout() {
try { await fetch(API_BASE + '/auth/logout', {method: 'POST'}); } catch(e) {} try { await fetch(API_BASE + '/auth/logout', {method: 'POST', credentials: 'include'}); } catch(e) {}
currentSession = null; currentSession = null;
practiceUsers = []; practiceUsers = [];
serverTasks = []; serverTasks = [];
@@ -721,11 +901,26 @@ async function doLogout() {
if (resetToken) { if (resetToken) {
stopPolling(); stopPolling();
document.getElementById('login-overlay').classList.remove('hidden'); document.getElementById('login-overlay').classList.remove('hidden');
renderResetPasswordForm(resetToken); try {
var vr = await fetch(
API_BASE + '/auth/reset_verify?reset_token=' + encodeURIComponent(resetToken)
);
var vd = await vr.json().catch(function() { return {valid: false}; });
if (!vd.valid) {
renderResetPasswordInvalid(vd.detail || 'Ung\u00fcltiger oder abgelaufener Link.');
return;
}
renderResetPasswordForm(resetToken);
} catch (e) {
renderResetPasswordInvalid('Verbindungsfehler. Bitte sp\u00e4ter erneut versuchen.');
}
return; return;
} }
var me = await checkAuth(); var me = await checkAuth();
if (me && me.practice_id) {
try { localStorage.setItem('aza_practice_id', me.practice_id); } catch(e) {}
}
if (!me) { if (!me) {
var inviteParam = urlParams.get('invite'); var inviteParam = urlParams.get('invite');
stopPolling(); stopPolling();
@@ -1629,7 +1824,7 @@ async function activateUser(userId) {
} }
async function resetPassword(userId, userName) { async function resetPassword(userId, userName) {
if (!confirm('Passwort von "' + userName + '" zuruecksetzen?')) return; if (!confirm('Passwort von "' + userName + '" zur\u00fccksetzen?')) return;
try { try {
var r = await apiFetch(API_BASE + '/admin/users/' + userId + '/reset_password', {method:'POST'}); var r = await apiFetch(API_BASE + '/admin/users/' + userId + '/reset_password', {method:'POST'});
var d = await r.json(); var d = await r.json();