update
This commit is contained in:
@@ -34,7 +34,7 @@ from aza_style import (
|
||||
|
||||
_MODULE_DESCRIPTIONS = {
|
||||
"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",
|
||||
"notizen": "Sprachaufnahmen und Notizen\nfuer den Praxisalltag",
|
||||
"translator": "Medizinische Fachtexte uebersetzen\nund Begriffe nachschlagen",
|
||||
@@ -69,6 +69,7 @@ def _draw_module_icon(c: tk.Canvas, key: str):
|
||||
fill=fg, outline="")
|
||||
|
||||
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)
|
||||
|
||||
elif key == "notizen":
|
||||
@@ -174,6 +175,7 @@ class AzaLauncher(tk.Tk):
|
||||
self.attributes("-topmost", True)
|
||||
|
||||
self._logo_img = None
|
||||
self._kg_tile_icon = None # gleiches logo.png wie Header, 38×38 für AzA-Office-Kachel
|
||||
try:
|
||||
import sys as _sys
|
||||
_search = []
|
||||
@@ -190,8 +192,17 @@ class AzaLauncher(tk.Tk):
|
||||
break
|
||||
if logo_path:
|
||||
from PIL import Image, ImageTk
|
||||
img = Image.open(logo_path).resize((44, 44), Image.Resampling.LANCZOS)
|
||||
self._logo_img = ImageTk.PhotoImage(img, master=self)
|
||||
_pil = Image.open(logo_path)
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -248,12 +259,12 @@ class AzaLauncher(tk.Tk):
|
||||
title_block = tk.Frame(title_row, bg=BG)
|
||||
title_block.pack(side="left", anchor="w")
|
||||
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")
|
||||
aza_lbl.pack(anchor="w")
|
||||
aza_lbl.bind("<Double-Button-1>", self._open_admin)
|
||||
tk.Label(title_block, text="Medizinischer KI-Arbeitsplatz",
|
||||
font=(FONT_FAMILY, 10), fg=SUBTLE, bg=BG
|
||||
tk.Label(title_block, text="von Arzt zu Arzt",
|
||||
font=(FONT_FAMILY, 11), fg="#1a4d6d", bg=BG
|
||||
).pack(anchor="w")
|
||||
|
||||
self._build_capacity_bar(header)
|
||||
@@ -528,14 +539,15 @@ class AzaLauncher(tk.Tk):
|
||||
top_row = tk.Frame(inner, bg=_cbg, cursor=_cursor)
|
||||
top_row.pack(fill="x", pady=(0, 10))
|
||||
|
||||
if mod_key == "kg" and self._logo_img:
|
||||
icon_lbl = tk.Label(top_row, image=self._logo_img, bg=_cbg, cursor=_cursor)
|
||||
icon_lbl.image = self._logo_img
|
||||
icon_lbl.pack(side="left")
|
||||
icon_cv = tk.Canvas(top_row, width=_ICON_SZ, height=_ICON_SZ,
|
||||
bg=icon_color, highlightthickness=0, cursor=_cursor)
|
||||
icon_cv.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:
|
||||
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)
|
||||
|
||||
lbl_title = tk.Label(inner, text=label, font=(FONT_FAMILY, 12, "bold"),
|
||||
|
||||
@@ -1601,6 +1601,19 @@ def telemetry_ping(data: TelemetryPing, request: Request):
|
||||
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")
|
||||
def telemetry_stats():
|
||||
uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds())
|
||||
@@ -1664,6 +1677,7 @@ def license_status(
|
||||
status = None
|
||||
current_period_end = None
|
||||
customer_email = None
|
||||
license_practice_id: Optional[str] = None
|
||||
try:
|
||||
try:
|
||||
import stripe_routes # type: ignore
|
||||
@@ -1677,7 +1691,7 @@ def license_status(
|
||||
if license_key and license_key.strip():
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT status, current_period_end, customer_email
|
||||
SELECT status, current_period_end, customer_email, practice_id
|
||||
FROM licenses
|
||||
WHERE upper(license_key) = ?
|
||||
ORDER BY updated_at DESC
|
||||
@@ -1688,7 +1702,7 @@ def license_status(
|
||||
if row is None and email and email.strip():
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT status, current_period_end, customer_email
|
||||
SELECT status, current_period_end, customer_email, practice_id
|
||||
FROM licenses
|
||||
WHERE lower(customer_email) = ?
|
||||
ORDER BY updated_at DESC
|
||||
@@ -1697,29 +1711,17 @@ def license_status(
|
||||
(email.strip().lower(),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
n_active = con.execute(
|
||||
"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")
|
||||
print("[LICENSE-STATUS] keine eindeutige Lizenzzeile (license_key oder customer_email erforderlich)")
|
||||
if row:
|
||||
status = row[0]
|
||||
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
|
||||
license_practice_id = str(row[3]).strip() if len(row) > 3 and row[3] else None
|
||||
except Exception:
|
||||
status = None
|
||||
current_period_end = None
|
||||
customer_email = None
|
||||
license_practice_id = None
|
||||
|
||||
decision = compute_license_decision(current_period_end=current_period_end, status=status)
|
||||
|
||||
@@ -1736,11 +1738,14 @@ def license_status(
|
||||
"used_devices": 0,
|
||||
"device_allowed": True,
|
||||
"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:
|
||||
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_name=device_name, app_version=app_version,
|
||||
device_fingerprint=device_fingerprint,
|
||||
@@ -1784,7 +1789,7 @@ def license_activate(
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT subscription_id, status, current_period_end, customer_email,
|
||||
allowed_users, devices_per_user
|
||||
allowed_users, devices_per_user, practice_id
|
||||
FROM licenses
|
||||
WHERE upper(license_key) = ?
|
||||
ORDER BY updated_at DESC
|
||||
@@ -1796,9 +1801,30 @@ def license_activate(
|
||||
if not row:
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
device_id = request.headers.get("X-Device-Id")
|
||||
@@ -1823,11 +1849,14 @@ def license_activate(
|
||||
"used_devices": 0,
|
||||
"device_allowed": True,
|
||||
"reason": "ok",
|
||||
"practice_id": practice_id,
|
||||
}
|
||||
|
||||
dev_user_key = practice_id.strip() if practice_id.strip() else "default"
|
||||
|
||||
if cust_email:
|
||||
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_name=device_name, app_version=app_version,
|
||||
device_fingerprint=device_fingerprint,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
7
AzA march 2026/empfang_app_settings.json
Normal file
7
AzA march 2026/empfang_app_settings.json
Normal 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
@@ -48,10 +48,20 @@
|
||||
"dermatology"
|
||||
],
|
||||
"ui_font_delta": 0,
|
||||
"global_right_click_paste": true,
|
||||
"global_right_click_paste": false,
|
||||
"todo_auto_open": false,
|
||||
"autocopy_after_diktat": 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",
|
||||
"diagnose_quelle": "",
|
||||
"dokumente_collapsed": false,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"⏺ Start": 33,
|
||||
"Übersetzen": 1
|
||||
"⏺ Start": 23,
|
||||
"🔑": 2,
|
||||
"👤": 6,
|
||||
"⏺ Aufnahme starten": 5,
|
||||
"Diktat": 1
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
420x380+1889+452
|
||||
420x380+1744+1053
|
||||
@@ -1 +1 @@
|
||||
908x1041+1071+79
|
||||
908x1041+1564+540
|
||||
@@ -1 +1 @@
|
||||
{"used": 521654, "total": 1000000, "budget_dollars": 0, "used_dollars": 0}
|
||||
{"used": 522567, "total": 1000000, "budget_dollars": 0, "used_dollars": 0}
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"name": "André M. Surovy",
|
||||
"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"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
1461 1205 1189 477 638 340
|
||||
1461 1205 1189 477 717 404
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"valid": true, "valid_until": 1777652509, "cached_at": 1775118734.4902344}
|
||||
{"valid": true, "valid_until": 1776783700, "cached_at": 1776697300.917711}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"transcript_vertical": 340,
|
||||
"kg_vertical": 329
|
||||
"transcript_vertical": 404,
|
||||
"kg_vertical": 535
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import smtplib
|
||||
import sqlite3
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
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")
|
||||
if "license_key" not in cols:
|
||||
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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
subscription_id: str,
|
||||
@@ -422,27 +478,36 @@ def _upsert_license(
|
||||
current_period_end: Optional[int],
|
||||
license_key: Optional[str] = None,
|
||||
) -> 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())
|
||||
with sqlite3.connect(DB_PATH) as con:
|
||||
existing_key = None
|
||||
existing_pid = ""
|
||||
row = con.execute(
|
||||
"SELECT license_key FROM licenses WHERE subscription_id = ?",
|
||||
"SELECT license_key, practice_id FROM licenses WHERE subscription_id = ?",
|
||||
(subscription_id,),
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
existing_key = row[0]
|
||||
if row:
|
||||
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_pid = existing_pid or _new_practice_id()
|
||||
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO licenses(
|
||||
subscription_id, customer_id, status, lookup_key,
|
||||
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
|
||||
customer_id=excluded.customer_id,
|
||||
status=excluded.status,
|
||||
@@ -453,7 +518,12 @@ def _upsert_license(
|
||||
client_reference_id=COALESCE(excluded.client_reference_id, client_reference_id),
|
||||
current_period_end=COALESCE(excluded.current_period_end, current_period_end),
|
||||
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,
|
||||
@@ -467,9 +537,21 @@ def _upsert_license(
|
||||
current_period_end,
|
||||
now,
|
||||
final_key,
|
||||
final_pid,
|
||||
),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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 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-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}
|
||||
@@ -386,7 +399,7 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
|
||||
|
||||
<div class="status-bar">
|
||||
<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>
|
||||
|
||||
<script>
|
||||
@@ -394,6 +407,9 @@ header h1{font-size:1.2rem;font-weight:600;letter-spacing:.3px;white-space:nowra
|
||||
STATE
|
||||
=================================================================== */
|
||||
var API_BASE = window.location.origin + '/empfang';
|
||||
function getPracticeIdOrEmpty() {
|
||||
try { return localStorage.getItem('aza_practice_id') || ''; } catch (e) { return ''; }
|
||||
}
|
||||
var currentSession = null;
|
||||
var practiceUsers = [];
|
||||
var serverTasks = [];
|
||||
@@ -443,7 +459,9 @@ var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 1
|
||||
API WRAPPER (global 401 handling)
|
||||
=================================================================== */
|
||||
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) {
|
||||
currentSession = null;
|
||||
stopPolling();
|
||||
@@ -458,7 +476,7 @@ async function apiFetch(url, opts) {
|
||||
=================================================================== */
|
||||
async function checkAuth() {
|
||||
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;
|
||||
var d = await r.json();
|
||||
if (d.authenticated) return d;
|
||||
@@ -471,7 +489,7 @@ async function showLoginOverlay() {
|
||||
var overlay = document.getElementById('login-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
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();
|
||||
if (d.needs_setup) { renderSetupForm(); return; }
|
||||
} catch(e) {}
|
||||
@@ -484,6 +502,7 @@ function hideLoginOverlay() {
|
||||
|
||||
function renderSetupForm() {
|
||||
var box = document.getElementById('login-box');
|
||||
box.className = 'login-box';
|
||||
box.innerHTML =
|
||||
'<h2>AZA Praxis-Chat einrichten</h2>' +
|
||||
'<p>Willkommen! Richten Sie Ihre Praxis und den ersten Administrator ein.</p>' +
|
||||
@@ -498,6 +517,7 @@ function renderSetupForm() {
|
||||
|
||||
function renderLoginForm() {
|
||||
var box = document.getElementById('login-box');
|
||||
box.className = 'login-box';
|
||||
var lastUser = localStorage.getItem('aza_last_login_user') || '';
|
||||
box.innerHTML =
|
||||
'<h2>Anmelden</h2>' +
|
||||
@@ -520,39 +540,159 @@ function renderLoginForm() {
|
||||
|
||||
function renderForgotPasswordForm() {
|
||||
var box = document.getElementById('login-box');
|
||||
box.className = 'login-box';
|
||||
var pid = getPracticeIdOrEmpty();
|
||||
box.innerHTML =
|
||||
'<h2>Passwort vergessen</h2>' +
|
||||
'<p>Geben Sie Ihre E-Mail-Adresse ein. Sie erhalten einen Link zum Zur\u00fccksetzen.</p>' +
|
||||
'<div class="login-field"><label>E-Mail</label><input type="email" id="forgot-email" autocomplete="email" placeholder="praxis@beispiel.ch"></div>' +
|
||||
'<p>Geben Sie Ihren <strong>Benutzernamen</strong> (wie in der Praxis angezeigt) oder Ihre <strong>E-Mail-Adresse</strong> ein.</p>' +
|
||||
'<p style="font-size:.82rem;color:#6a8a9a">Nach einer erfolgreichen Anmeldung speichert der Browser die Praxiszuordnung — 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>' +
|
||||
'<div class="login-error" id="login-error"></div>' +
|
||||
'<div class="login-switch"><a onclick="renderLoginForm()">← 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()">← 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() {
|
||||
var email = (document.getElementById('forgot-email').value || '').trim();
|
||||
var raw = (document.getElementById('forgot-login').value || '').trim();
|
||||
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 {
|
||||
var r = await fetch(API_BASE + '/auth/forgot_password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({email: email})
|
||||
method: 'POST',
|
||||
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.textContent = d.message || 'Reset-Link wurde gesendet.';
|
||||
} catch(e) { errEl.textContent = 'Fehler beim Senden.'; }
|
||||
errEl.textContent = d.message || 'Wenn ein passendes Konto existiert, wurde ein Link gesendet.';
|
||||
} 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) {
|
||||
var box = document.getElementById('login-box');
|
||||
box.className = 'login-box';
|
||||
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>' +
|
||||
'<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>' +
|
||||
'<button class="login-btn" onclick="doResetPassword(\'' + resetToken + '\')">Passwort speichern</button>' +
|
||||
'<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 + '\')">Neues Passwort speichern</button>' +
|
||||
'<div class="login-error" id="login-error"></div>';
|
||||
document.getElementById('reset-pass').focus();
|
||||
}
|
||||
@@ -568,23 +708,34 @@ async function doResetPassword(resetToken) {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
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) {
|
||||
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.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);
|
||||
} 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() {
|
||||
var box = document.getElementById('login-box');
|
||||
box.className = 'login-box login-register';
|
||||
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>' +
|
||||
'<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>Passwort (min. 4 Zeichen)</label><input type="password" id="reg-pass" autocomplete="new-password"></div>' +
|
||||
'<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; }
|
||||
try {
|
||||
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})
|
||||
});
|
||||
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.';
|
||||
} else {
|
||||
var d = await r.json().catch(function(){ return {}; });
|
||||
@@ -641,20 +797,39 @@ async function doLogin() {
|
||||
var name = (document.getElementById('login-name').value || '').trim();
|
||||
var pass = document.getElementById('login-pass').value || '';
|
||||
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 {
|
||||
var r = await fetch(API_BASE + '/auth/login', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, password: pass})
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
password: pass,
|
||||
practice_id: getPracticeIdOrEmpty()
|
||||
})
|
||||
});
|
||||
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.';
|
||||
} else {
|
||||
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() {
|
||||
@@ -666,10 +841,15 @@ async function doRegister() {
|
||||
if (!code || !name || !pass) { errEl.textContent = 'Alle Felder erforderlich.'; return; }
|
||||
try {
|
||||
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})
|
||||
});
|
||||
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.';
|
||||
} else {
|
||||
var d = await r.json().catch(function(){ return {}; });
|
||||
@@ -679,7 +859,7 @@ async function doRegister() {
|
||||
}
|
||||
|
||||
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;
|
||||
practiceUsers = [];
|
||||
serverTasks = [];
|
||||
@@ -721,11 +901,26 @@ async function doLogout() {
|
||||
if (resetToken) {
|
||||
stopPolling();
|
||||
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;
|
||||
}
|
||||
|
||||
var me = await checkAuth();
|
||||
if (me && me.practice_id) {
|
||||
try { localStorage.setItem('aza_practice_id', me.practice_id); } catch(e) {}
|
||||
}
|
||||
if (!me) {
|
||||
var inviteParam = urlParams.get('invite');
|
||||
stopPolling();
|
||||
@@ -1629,7 +1824,7 @@ async function activateUser(userId) {
|
||||
}
|
||||
|
||||
async function resetPassword(userId, userName) {
|
||||
if (!confirm('Passwort von "' + userName + '" zuruecksetzen?')) return;
|
||||
if (!confirm('Passwort von "' + userName + '" zur\u00fccksetzen?')) return;
|
||||
try {
|
||||
var r = await apiFetch(API_BASE + '/admin/users/' + userId + '/reset_password', {method:'POST'});
|
||||
var d = await r.json();
|
||||
|
||||
Reference in New Issue
Block a user