diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index a703ed3..14c70eb 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -44,7 +44,7 @@ funktioniert trotzdem, die Ausgabe ist dann unformatiert. 6. [Einstellungen und Konfiguration](#6-einstellungen-und-konfiguration) **Administration** -7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) +7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) · [7.6 Anmeldung & Admin-Zugang](#76-anmeldung--admin-zugang-link-passwort-recovery) 8. [Gedächtnis und Erinnerungen](#8-gedächtnis-und-erinnerungen) 9. [Resilienz, Fallbacks und Metriken](#9-resilienz-fallbacks-und-metriken) 10. [Notfall-Erkennung und Eskalation](#10-notfall-erkennung-und-eskalation) @@ -1798,6 +1798,13 @@ Nutzer anlegen (Name eingeben → „Anlegen" → Token erscheint **einmalig** umbenennen, Token zurücksetzen und löschen. Erinnerungen je Nutzer auf- und zuklappen, neue Erinnerungen hinzufügen oder vorhandene löschen. +Im **Notfall-Profil** jedes Nutzers (aufklappbar) stehen Kontaktdaten, med. Hinweise, +„Standort im Notfall mitsenden" und **„Pause bis zum erneuten Alarm"** (Minuten): nach +einem ausgelösten Notruf ist ein erneuter Alarm so lange blockiert (Schutz vor +Mehrfach-Alarmen). **Leer** = der globale Standard greift (Tab *Konfiguration › +Einstellungen* → „Notruf-Sperre (Minuten)", Default 5), **0** = aus. Präzedenz: +Nutzer-Wert > globale Einstellung > 5. + #### Nutzer › Gespräche Nutzerliste links → Session auswählen → Gesprächs-Transkript als Chat-Bubbles ansehen. @@ -1865,6 +1872,86 @@ Protokolliert werden u. a. `config_set` / `config_reset` (Laufzeit-Einstellungen > Sichtbar im Log-Tab nur im **Dienst-Betrieb** (Journal). Im Vordergrund-Betrieb > (`make run`) erscheinen die Audit-Zeilen im Terminal. +### 7.6 Anmeldung & Admin-Zugang (Link, Passwort, Recovery) + +> 🔧 Admin + +Die App-Oberfläche (`/`) erkennt Dich an einem `va_token`-Cookie. Es gibt drei Wege hinein: +1. **Persönlicher Zugangslink** (`?k=`) — der Standardweg für Senioren (keine + Login-Maske); ein gültiges Cookie loggt automatisch ein. +2. **Benutzername & Passwort (1FA, ohne 2FA)** — für Betreuer/Admins (§ 7.6.1). +3. **Recovery-Wege**, falls ein Admin ausgesperrt ist (§ 7.6.3 / 7.6.4). + +Ohne gültiges Cookie zeigt `/` die Seite „Persönlicher Zugang nötig" mit einem Knopf +**„Mit Benutzername & Passwort anmelden"** (→ `/login`). Senioren nutzen weiter ihren Link; +`/` leitet bewusst **nicht** automatisch zur Login-Maske um (sonst säßen Senioren ohne +Authelia-Konto fest). + +#### 7.6.1 Anmeldung mit Benutzername & Passwort (1FA, ohne 2FA) + +Rufe auf (oder klicke den Knopf auf der Zugangs-Seite): + +``` +https://voice.jamulix.de/login +``` + +Ablauf: Der Reverse-Proxy schickt Dich zu **Authelia** (`auth.jamulix.de`) → Du gibst +**nur Benutzername + Passwort** ein (Zugriffsregel `voice.jamulix.de → one_factor`, also +**kein 2FA**) → das Gateway erkennt Dich am `Remote-User`, setzt ein frisches +`va_token`-Cookie und leitet in die App (`/`). Gilt für **Admins und normale +Authelia-Nutzer**; die Konten liegen im Authelia-Datei-Backend +(`/etc/authelia/users_database.yml`). + +- Endpunkt dahinter: `GET /api/login` (nginx-Location `/login`). +- **Jeder Login rotiert Dein Token** — ein zuvor gesetztes Cookie / ein alter Link + *dieses Nutzers* wird ungültig (ein Token pro Nutzer). +- Andere `*.jamulix.de`-Dienste bleiben bei **2FA**; nur `voice.jamulix.de` ist 1FA. + Sicherheits-Hinweis: 1FA für den Admin-Zugang ist schwächer als 2FA — bewusste Abwägung. + +#### 7.6.2 Admin-Selbstanmeldung (`/admin-login`) + +`https://voice.jamulix.de/admin-login` ist die auf **Admins** (`ADMIN_USERS`) beschränkte +Variante von `/login` — gleicher Ablauf, aber Nicht-Admins bekommen 403. Praktisch als +Bookmark „Admin-Login". Endpunkt: `GET /api/admin/login` (nginx-Alias `/admin-login`). + +#### 7.6.3 Recovery per `ADMIN_API_KEY` (SSO-unabhängig) + +Funktioniert auch, wenn Authelia gerade nicht erreichbar ist — vom Server/localhost aus. +Den Key liest Du als root aus `/etc/voice-assistant/voice-assistant.env`. + +```bash +# 1) Deine user_id finden: +curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" \ + | jq -r '.[] | select(.external_id=="dschlueter") | .user_id' + +USER_ID= + +# 2) Neuen Token ausstellen (wird EINMALIG zurückgegeben): +TOKEN=$(curl -s -X POST $URL/api/admin/users/$USER_ID/token \ + -H "X-Admin-Key: $ADMIN_API_KEY" | jq -r '.token') + +# 3) Daraus den Zugangslink bauen: +echo "https://voice.jamulix.de/?k=$TOKEN" +``` + +> Der `ADMIN_API_KEY` ist sehr mächtig (voller Admin-Zugriff). Nicht in Proxy-Logs +> geraten lassen (kein `?key=` in URLs verwenden, außer beim einmaligen Bootstrap über +> `GET /api/admin/request-headers?key=…`), und nach solchem Gebrauch rotieren. + +#### 7.6.4 Härtung gegen Total-Aussperrung + +Eine echte Vollsperre bräuchte: Link weg **und** Authelia-Konto weg **und** +`ADMIN_API_KEY` weg **und** kein SSH. Um auch das abzusichern: + +- **Zweiter Admin:** einen weiteren Authelia-Namen in `ADMIN_USERS` aufnehmen (CSV), + damit ein einzelnes verlorenes Konto nicht alles blockiert: + ```bash + # in /etc/voice-assistant/voice-assistant.env, dann Gateway neu starten: + ADMIN_USERS=dschlueter, + ``` +- **`ADMIN_API_KEY` sichern:** im Passwortmanager hinterlegen (der Wert steht in + `/etc/voice-assistant/voice-assistant.env`). + --- ## 8. Gedächtnis und Erinnerungen diff --git a/app/api/admin.py b/app/api/admin.py index 22db3eb..fb640bf 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,7 +6,7 @@ import httpx import yaml from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from pydantic import BaseModel from app.admin_llm import ( @@ -16,7 +16,7 @@ from app.admin_llm import ( switch_backend, ) from app.audit import log_admin_action -from app.auth import is_admin_user, require_admin, require_admin_or_user +from app.auth import CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings @@ -66,6 +66,50 @@ def _current_user_or_none(request: Request): return authenticate(request.headers, client_host, token) +def _issue_login_cookie(request: Request, user, action: str): + """Mintet ein frisches Capability-Token fuer `user`, setzt es als va_token-Cookie + und leitet in die App (/). Gemeinsame Basis fuer /api/login und /api/admin/login. + Jeder Aufruf rotiert das Token (ein Token pro Nutzer).""" + result = get_store().reset_token(user.id) + if result is None: + raise HTTPException(status_code=404, detail="Nutzer nicht gefunden.") + _, raw_token = result + log_admin_action(request, action, user_id=user.id) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie(CAPABILITY_COOKIE, raw_token, httponly=True, secure=True, + samesite="lax", max_age=31536000) + return resp + + +@router.get("/login") +async def app_login(request: Request): + """Anmeldung in die App mit Benutzername/Passwort (1FA) statt persoenlichem Link. + + Liegt hinter Authelia (nginx: Location /login, one_factor). Erkennt den per + Remote-User weitergereichten Authelia-Nutzer (Admin ODER normaler Nutzer), + mintet das va_token-Cookie und leitet in die App. Senioren nutzen weiter ihren + Link; dieser Weg ist der Passwort-Einstieg fuer Betreuer/Admins. + """ + user = _current_user_or_none(request) + if user is None: + raise HTTPException(status_code=403, detail="Authentication required") + return _issue_login_cookie(request, user, "app_login") + + +@router.get("/admin/login") +async def admin_app_login(request: Request): + """Admin-Selbstanmeldung in die App — Ersatz fuer einen verlorenen Zugangslink. + + Wie /api/login, aber zusaetzlich auf Admins beschraenkt (ADMIN_USERS). Liegt + hinter Authelia (nginx: Location /admin-login). Mintet das va_token-Cookie und + leitet in die App (/). + """ + user = _current_user_or_none(request) + if not is_admin_user(user): + raise HTTPException(status_code=403, detail="Admin privileges required") + return _issue_login_cookie(request, user, "admin_app_login") + + @router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)]) async def create_user(payload: UserCreate): """Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck.""" @@ -177,7 +221,7 @@ async def list_users(): **{k: (u.prefs or {}).get(k, "") for k in ( "full_name", "street", "postal_code", "city", "phone_landline", "phone_mobile", "birth_date", "medical_notes", - "emergency_contacts", "emergency_phones", + "emergency_contacts", "emergency_phones", "emergency_cooldown_minutes", )}, "location_consent": bool((u.prefs or {}).get("location_consent", False)), "is_admin": is_admin_user(u), diff --git a/app/api/chat.py b/app/api/chat.py index 26ac77b..8098f60 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -17,7 +17,8 @@ from app.dependencies import ( ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError -from app.safety.emergency import record_manual_emergency +from app.runtime_config import runtime_settings +from app.safety.emergency import record_manual_emergency, emergency_cooldown_status from app.schemas import ChatRequest, EmergencyRequest router = APIRouter() @@ -153,4 +154,12 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ return await record_manual_emergency( user, store, payload.language, geo=payload.geo, geo_error=payload.geo_error, device=payload.device, + cfg=runtime_settings, ) + + +@router.get("/emergency/status") +async def emergency_status(user: User = Depends(require_user)): + """Aktueller Notruf-Sperr-Status des Nutzers (für die grüne Button-Anzeige, + auch nach einem Seiten-Neuladen).""" + return emergency_cooldown_status(user, get_store(), runtime_settings) diff --git a/app/config.py b/app/config.py index 4c2ffc1..4f8c6cb 100644 --- a/app/config.py +++ b/app/config.py @@ -206,6 +206,10 @@ class Settings(BaseSettings): emergency_webhook_token: str = "" # optional -> Authorization: Bearer emergency_contact_phone: str = "" # Default-Telefonnummer(n), CSV, E.164 (+49…) emergency_webhook_channels: str = "sms,call" # angefragte Kanäle (CSV) + # Sperre nach ausgelöstem Notruf: erneuter Alarm ist N Minuten blockiert. + # Verhindert, dass verängstigte/verwirrte Senioren mehrfach hintereinander + # alarmieren. 0 = keine Sperre. Im Admin-Bereich zur Laufzeit änderbar. + emergency_cooldown_minutes: int = 5 model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index cff898a..af2e6ac 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,3 +1,5 @@ +import asyncio + from app.schemas import AudioChunk, PipelineTrace from app.pipeline.sentence_chunker import SentenceChunker from app.metrics import timer, metrics @@ -197,49 +199,92 @@ class Orchestrator: parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) - if stream_fn is not None: - async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): - parts.append(delta) - if on_token: - await on_token(delta) - if chunker: - for sentence in chunker.feed(delta): - await _emit_sentence(sentence) - else: - result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) - parts.append(result) - if on_token: - await on_token(result) - if chunker: - for sentence in chunker.feed(result): + + # Producer/Consumer: Die satzweise TTS-Synthese läuft in einem eigenen + # Consumer-Task, damit das Token-Streaming (Text in der Bubble) nicht + # mehr satzweise auf die Synthese wartet. Ein *einzelner* Consumer + # garantiert die Reihenfolge der Audio-Chunks — harte Invariante fürs + # gapless Playback im Client; out-of-order würde das Audio zerstören. + queue: asyncio.Queue | None = asyncio.Queue() if chunker else None + consumer_task: asyncio.Task | None = None + + async def _consume() -> None: + while True: + sentence = await queue.get() + try: + if sentence is None: # Sentinel: keine Sätze mehr + return await _emit_sentence(sentence) + finally: + queue.task_done() - trace.semantic_response = "".join(parts) - if not trace.semantic_response: - raise RuntimeError("LLM returned an empty response") + async def _dispatch(sentence: str) -> None: + # Satz in die Queue legen und sofort weiterlesen (kein await auf die + # Synthese). Ist der Consumer zuvor an einer Synthese-Exception + # gestorben, diese sofort hochreichen, statt weiter zu puffern. + if consumer_task is not None and consumer_task.done(): + await consumer_task + await queue.put(sentence) - trace.spoken_response = await self.spoken_adapter.run( - trace.semantic_response, - language=effective_language, - ) - trace.tts_ready_text = await self.tts_normalizer.run( - trace.spoken_response, - language=effective_language, - level=self.normalize_level, - ) + if queue is not None: + consumer_task = asyncio.create_task(_consume()) - if text_only: - return trace, b"" + try: + if stream_fn is not None: + async for delta in stream_fn(trace.cleaned_transcript or "", history=history, language=effective_language): + parts.append(delta) + if on_token: + await on_token(delta) + if chunker: + for sentence in chunker.feed(delta): + await _dispatch(sentence) + else: + result = await self.llm.complete(trace.cleaned_transcript or "", history=history, language=effective_language) + parts.append(result) + if on_token: + await on_token(result) + if chunker: + for sentence in chunker.feed(result): + await _dispatch(sentence) - if chunker: - tail = chunker.flush() - if tail: - await _emit_sentence(tail) - audio = b"".join(audio_parts) - else: - audio = await self.tts.synthesize( - trace.tts_ready_text, voice=voice, language=effective_language + trace.semantic_response = "".join(parts) + if not trace.semantic_response: + raise RuntimeError("LLM returned an empty response") + + trace.spoken_response = await self.spoken_adapter.run( + trace.semantic_response, + language=effective_language, ) + trace.tts_ready_text = await self.tts_normalizer.run( + trace.spoken_response, + language=effective_language, + level=self.normalize_level, + ) + + if text_only: + return trace, b"" + + if chunker: + tail = chunker.flush() + if tail: + await _dispatch(tail) + await queue.put(None) # Sentinel: Consumer beenden + await consumer_task # auf restliche Synthese warten + Exceptions propagieren + consumer_task = None + audio = b"".join(audio_parts) + else: + audio = await self.tts.synthesize( + trace.tts_ready_text, voice=voice, language=effective_language + ) + finally: + # Bei Fehler/Abbruch den noch laufenden Consumer-Task abräumen, + # damit kein verwaister Task zurückbleibt. + if consumer_task is not None and not consumer_task.done(): + consumer_task.cancel() + try: + await consumer_task + except (asyncio.CancelledError, Exception): + pass await self._emit_to_output(audio, output) return trace, audio diff --git a/app/main.py b/app/main.py index 914c9f2..656a04e 100644 --- a/app/main.py +++ b/app/main.py @@ -75,11 +75,14 @@ _NO_ACCESS_PAGE = """ +p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem} +.btn{display:inline-block;margin-top:1.25rem;background:#2563eb;color:#fff;text-decoration:none; +font-weight:600;font-size:1.05rem;padding:.8rem 1.4rem;border-radius:.75rem}
🔒

Persönlicher Zugang nötig

-

Bitte öffne die App über deinen persönlichen Zugangslink.

-

Wenn du keinen Link hast, wende dich an deine Betreuungsperson.

+

Senioren: bitte die App über den persönlichen Zugangslink öffnen.

+

Betreuer und Admins können sich mit Benutzername und Passwort anmelden:

+Mit Benutzername & Passwort anmelden
""" diff --git a/app/runtime_config.py b/app/runtime_config.py index 4f67f48..aaef3ea 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -33,6 +33,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), + "emergency_cooldown_minutes": ("Notruf-Sperre (Minuten)", "int", "Erneuter Alarm für N Minuten blockiert (Standard 5, 0 = aus)"), } _TTL = 30.0 diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 70f5062..1a0d7fb 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -270,10 +270,74 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool: return False +def _resolve_cooldown_minutes(user, cfg) -> int: + """Alarmpause in Minuten. Präzedenz: Nutzer-Pref > globale Einstellung > 5. + Leerer/fehlender Pref-Wert erbt die globale Einstellung; "0" bedeutet aus. + """ + raw = (getattr(user, "prefs", None) or {}).get("emergency_cooldown_minutes") + if raw is not None and str(raw).strip() != "": + try: + return max(0, int(raw)) + except (TypeError, ValueError): + pass + try: + return max(0, int(getattr(cfg, "emergency_cooldown_minutes", 5))) + except (TypeError, ValueError): + return 5 + + +def _cooldown_seconds(user, cfg) -> int: + return _resolve_cooldown_minutes(user, cfg) * 60 + + +def emergency_cooldown_status(user, store, cfg=settings) -> dict: + """Aktueller Sperr-Status: ist nach dem letzten manuellen Notruf noch + eine Wiederauslöse-Sperre aktiv, wie lange ist er her, wie viel bleibt. + + Schützt verwirrte/verängstigte Senioren vor Mehrfach-Alarmen. Die Pause ist + pro Nutzer einstellbar (sonst greift die globale Einstellung). + """ + total = _cooldown_seconds(user, cfg) + last = store.last_manual_emergency_at(user.id) if total > 0 else None + base = {"active": False, "cooldown_seconds": total, "remaining_seconds": 0, "minutes_ago": 0} + if not last: + return base + try: + last_dt = datetime.fromisoformat(last) + except (ValueError, TypeError): + return base + elapsed = (datetime.now(timezone.utc) - last_dt).total_seconds() + remaining = int(total - elapsed) + return { + "active": remaining > 0, + "cooldown_seconds": total, + "remaining_seconds": max(0, remaining), + "minutes_ago": max(0, int(elapsed // 60)), + } + + async def record_manual_emergency(user, store, language: str | None = None, geo=None, geo_error: str | None = None, device=None, cfg=settings) -> dict: - """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.""" + """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis. + + Innerhalb der Wiederauslöse-Sperre (emergency_cooldown_minutes) wird KEIN + neuer Alarm gesendet und kein Ereignis protokolliert — stattdessen liefert + die Antwort status='cooldown' mit minutes_ago/remaining_seconds, damit das + Frontend den beruhigenden Hinweis zeigen kann. + """ + cd = emergency_cooldown_status(user, store, cfg) + if cd["active"]: + metrics.inc("emergency_total", {"category": "manual", "source": "user_suppressed"}) + return { + "category": "manual", + "status": "cooldown", + "cooldown_seconds": cd["cooldown_seconds"], + "remaining_seconds": cd["remaining_seconds"], + "minutes_ago": cd["minutes_ago"], + "notice": notify_notice(language, True), + } + # Bewusst minimaler Log-Eintrag — KEINE Adresse/Koordinaten (Admin-Log bleibt unkritisch). store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf") metrics.inc("emergency_total", {"category": "manual", "source": "user"}) @@ -302,6 +366,8 @@ async def record_manual_emergency(user, store, language: str | None = None, notified = email_sent or webhook_sent return { "category": "manual", + "status": "alerted", + "cooldown_seconds": cd["cooldown_seconds"], "notice": notify_notice(language, notified), "email_sent": email_sent, "webhook_sent": webhook_sent, diff --git a/app/schemas.py b/app/schemas.py index ac7f96c..ec6ab5f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -174,6 +174,9 @@ class AdminUserPrefsUpdate(BaseModel): emergency_contacts: str | None = None # Kontakt-E-Mail(s), CSV emergency_phones: str | None = None # Kontakt-Telefon(e), CSV location_consent: bool | None = None # Standort im Notfall mitsenden? + # Alarmpause pro Nutzer (ganze Minuten, als String). Leer = globaler Standard, + # "0" = aus. Präzedenz: dieser Wert > globale Einstellung > 5. + emergency_cooldown_minutes: str | None = None @field_validator("postal_code") @classmethod @@ -210,6 +213,18 @@ class AdminUserPrefsUpdate(BaseModel): raise ValueError("Kontakt-Telefon(e): ungültige Nummer") return v + @field_validator("emergency_cooldown_minutes") + @classmethod + def _check_cooldown(cls, v): + if v is None or str(v).strip() == "": + return v # leer = globaler Standard (erben) + s = str(v).strip() + if not s.isdigit(): + raise ValueError("Pause: nur ganze Minuten (0–60)") + if int(s) > 60: + raise ValueError("Pause: höchstens 60 Minuten") + return s + class UserAdminUpdate(BaseModel): """Admin schaltet Admin-Rechte für einen Nutzer ein/aus.""" diff --git a/app/store.py b/app/store.py index 8e3d9be..c3252e7 100644 --- a/app/store.py +++ b/app/store.py @@ -148,6 +148,9 @@ class Store(ABC): @abstractmethod def list_emergency_events(self, limit: int = 50) -> list[dict]: ... + @abstractmethod + def last_manual_emergency_at(self, user_id: str) -> str | None: ... + @abstractmethod def get_usage_for_user(self, user_id: str) -> list[dict]: ... @@ -561,6 +564,17 @@ class SQLiteStore(Store): ).fetchall() return [dict(r) for r in rows] + def last_manual_emergency_at(self, user_id: str) -> str | None: + """ISO-Zeitstempel des letzten vom Nutzer ausgelösten Notrufs (oder None).""" + with self._connect() as conn: + row = conn.execute( + "SELECT created_at FROM emergency_events" + " WHERE user_id = ? AND category = 'manual'" + " ORDER BY id DESC LIMIT 1", + (user_id,), + ).fetchone() + return row["created_at"] if row else None + def get_usage_for_user(self, user_id: str) -> list[dict]: with self._connect() as conn: rows = conn.execute( diff --git a/app/web/app.js b/app/web/app.js index 7371de8..93783f9 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -368,6 +368,7 @@ const PROF_VALIDATORS = { birth_date: { ok: _isGermanDate, msg: "Geburtsdatum: TT.MM.JJJJ" }, emergency_contacts: { ok: (v) => _csvAll(v, _isEmail), msg: "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" }, emergency_phones: { ok: (v) => _csvAll(v, _isPhone), msg: "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" }, + emergency_cooldown_minutes: { ok: (v) => /^\d+$/.test(v) && +v <= 60, msg: "Pause: 0–60 ganze Minuten" }, }; // Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt. @@ -422,6 +423,7 @@ function loadMe() { applyAllowedLanguages(prefs); applyPlaybackChoice(prefs.tts_provider); applyGenderChoice(prefs.voice_gender || 'f'); + initSosCooldown(); // grünen Sperr-Zustand nach Reload wiederherstellen return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; @@ -840,6 +842,9 @@ function newConversation() { }, 2500); } $("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); }); +// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü). +const newChatTop = $("#new-chat-top"); +if (newChatTop) newChatTop.addEventListener("click", newConversation); // ---------- Menü (⋮) verdrahten ---------- $("#menu-toggle").addEventListener("click", () => @@ -1187,6 +1192,13 @@ function buildUserCard(u) { + +

leer = globaler Standard · 0 = aus

@@ -1237,7 +1249,8 @@ function buildUserCard(u) { // Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe. const PROF_KEYS = ["full_name", "street", "postal_code", "city", "phone_mobile", "phone_landline", "birth_date", "medical_notes", - "emergency_contacts", "emergency_phones"]; + "emergency_contacts", "emergency_phones", + "emergency_cooldown_minutes"]; PROF_KEYS.forEach((k) => { const el = card.querySelector(`.prof-f[data-k="${k}"]`); if (el) el.value = u[k] || ""; @@ -2245,29 +2258,145 @@ function deviceInfo() { return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) }; } -if (emergencyBtn) { - emergencyBtn.addEventListener("click", async () => { - if (!confirm("Wirklich Hilfe rufen?\nIch alarmiere sofort Helfer!")) return; - const lang = (langSel && langSel.value) || "de"; +// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl): +// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend — +// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter. +function setSosState(state) { + if (!emergencyBtn) return; + emergencyBtn.classList.remove( + "bg-red-600", "hover:bg-red-700", "bg-green-600", "hover:bg-green-700", + "animate-pulse", "text-sm", "text-lg" + ); + if (state === "calling") { + emergencyBtn.textContent = "SOS"; // pulsiert = „wird gerufen“ emergencyBtn.disabled = true; - try { - const g = await captureGeo(); - const body = { language: lang, device: deviceInfo() }; - if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts }; - else body.geo_error = g.error; - const res = await fetch("/api/emergency", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await res.json().catch(() => ({})); - const notice = (data && data.notice) || - "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut."; - addMessage("emergency", "🆘 " + notice); - } catch (e) { - addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); - } finally { - emergencyBtn.disabled = false; + emergencyBtn.classList.add("bg-red-600", "animate-pulse", "text-sm"); + } else if (state === "done") { + emergencyBtn.textContent = "✓"; // grün + Haken + pulsiert = Alarm raus + emergencyBtn.disabled = false; // klickbar -> beruhigender Hinweis + emergencyBtn.classList.add("bg-green-600", "hover:bg-green-700", "animate-pulse", "text-lg"); + } else if (state === "error") { + emergencyBtn.textContent = "!"; + emergencyBtn.disabled = false; + emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-lg"); + } else { // idle + emergencyBtn.textContent = "SOS"; + emergencyBtn.disabled = false; + emergencyBtn.classList.add("bg-red-600", "hover:bg-red-700", "text-sm"); + } +} + +// ----- Wiederauslöse-Sperre (Cooldown) ----- +// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün +// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt +// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen. +let sosCooldownActive = false; +let sosAlertedAtMs = 0; +let sosCooldownTimer = null; + +function enterCooldown(remainingSec, minutesAgo) { + sosCooldownActive = true; + // Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload). + sosAlertedAtMs = Date.now() - Math.max(0, minutesAgo || 0) * 60000; + setSosState("done"); + if (sosCooldownTimer) clearTimeout(sosCooldownTimer); + if (remainingSec > 0) sosCooldownTimer = setTimeout(exitCooldown, remainingSec * 1000); + else exitCooldown(); +} + +function exitCooldown() { + sosCooldownActive = false; + sosCooldownTimer = null; + setSosState("idle"); +} + +function sosMinutesAgo() { + return Math.max(0, Math.floor((Date.now() - sosAlertedAtMs) / 60000)); +} + +async function runEmergency() { + const lang = (langSel && langSel.value) || "de"; + setSosState("calling"); + try { + const g = await captureGeo(); + const body = { language: lang, device: deviceInfo() }; + if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts }; + else body.geo_error = g.error; + const res = await fetch("/api/emergency", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (data && data.status === "cooldown") { + // Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm. + enterCooldown(data.remaining_seconds || 0, data.minutes_ago || 0); + openSosCooldown(); + return; } + const notice = (data && data.notice) || + "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut."; + addMessage("emergency", "🆘 " + notice); + const cd = (data && data.cooldown_seconds) || 0; + if (cd > 0) { + enterCooldown(cd, 0); // grün/pulsierend für die Sperre + } else { + setSosState("done"); // Sperre aus -> nur kurzes Erfolgs-Feedback + setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 6000); + } + } catch (e) { + addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs."); + setSosState("error"); + setTimeout(() => { if (!sosCooldownActive) setSosState("idle"); }, 4000); + } +} + +// Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich. +const sosConfirm = $("#sos-confirm"); +function openSosConfirm() { if (sosConfirm) sosConfirm.classList.remove("hidden"); } +function closeSosConfirm() { if (sosConfirm) sosConfirm.classList.add("hidden"); } + +// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird. +const sosCooldownDlg = $("#sos-cooldown"); +function openSosCooldown() { + const m = sosMinutesAgo(); + const when = m < 1 ? "gerade eben" : ("vor " + m + (m === 1 ? " Minute" : " Minuten")); + const txtEl = $("#sos-cooldown-text"); + if (txtEl) txtEl.textContent = + "Ich habe Deine Helfer " + when + " alarmiert. " + + "Wahrscheinlich ist Deine Hilfe schon unterwegs."; + if (sosCooldownDlg) sosCooldownDlg.classList.remove("hidden"); +} +function closeSosCooldown() { if (sosCooldownDlg) sosCooldownDlg.classList.add("hidden"); } + +// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch +// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu). +async function initSosCooldown() { + try { + const res = await fetch("/api/emergency/status", { headers: { Accept: "application/json" } }); + if (!res.ok) return; + const s = await res.json(); + if (s && s.active) enterCooldown(s.remaining_seconds || 0, s.minutes_ago || 0); + } catch (e) { /* ignore */ } +} + +if (emergencyBtn) { + emergencyBtn.addEventListener("click", () => { + if (sosCooldownActive) openSosCooldown(); // schon alarmiert -> nur beruhigen + else openSosConfirm(); // sonst Notruf bestätigen }); } +const sosYesBtn = $("#sos-confirm-yes"); +const sosNoBtn = $("#sos-confirm-no"); +if (sosYesBtn) sosYesBtn.addEventListener("click", () => { closeSosConfirm(); runEmergency(); }); +if (sosNoBtn) sosNoBtn.addEventListener("click", closeSosConfirm); +const sosCooldownOkBtn = $("#sos-cooldown-ok"); +if (sosCooldownOkBtn) sosCooldownOkBtn.addEventListener("click", closeSosCooldown); +// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm). +if (sosConfirm) sosConfirm.addEventListener("click", (e) => { if (e.target === sosConfirm) closeSosConfirm(); }); +if (sosCooldownDlg) sosCooldownDlg.addEventListener("click", (e) => { if (e.target === sosCooldownDlg) closeSosCooldown(); }); +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if (sosConfirm && !sosConfirm.classList.contains("hidden")) closeSosConfirm(); + if (sosCooldownDlg && !sosCooldownDlg.classList.contains("hidden")) closeSosCooldown(); +}); diff --git a/app/web/index.html b/app/web/index.html index f1c782c..27510af 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -229,7 +229,7 @@

Voice Assistant

+ class="shrink-0 h-8 w-8 grid place-items-center rounded-lg bg-red-600 hover:bg-red-700 text-white font-extrabold text-sm leading-none shadow-sm transition-colors disabled:opacity-100">SOS + + @@ -365,6 +372,45 @@
- + + + + + + + diff --git a/tests/test_admin_app_login.py b/tests/test_admin_app_login.py new file mode 100644 index 0000000..3047e3d --- /dev/null +++ b/tests/test_admin_app_login.py @@ -0,0 +1,96 @@ +"""Tests fuer die Admin-Selbstanmeldung in die App (GET /api/admin/login). + +Der Endpunkt liegt in Produktion hinter Authelia (nginx setzt Remote-User). +Hier wird die Forward-Auth ueber den vertrauten Proxy simuliert. +""" +import pytest +from fastapi.testclient import TestClient + +import app.dependencies as deps +from app.auth import CAPABILITY_COOKIE +from app.config import settings +from app.main import app + +client = TestClient(app) + +HEADER = "X-Remote-User" +# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen. +TRUSTED = "testclient" + + +@pytest.fixture +def forward_auth(monkeypatch): + monkeypatch.setattr(settings, "trusted_auth_header", HEADER) + monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED) + monkeypatch.setattr(settings, "admin_users", "dschlueter") + monkeypatch.setattr(settings, "auth_enabled", True) + + +def test_admin_login_sets_cookie_and_redirects(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + set_cookie = r.headers.get("set-cookie", "") + assert f"{CAPABILITY_COOKIE}=" in set_cookie + assert "HttpOnly" in set_cookie + + +def test_admin_login_cookie_token_authenticates(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + # Den frisch gesetzten Token aus dem Set-Cookie ziehen ... + raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + # ... und pruefen, dass er den Admin-Nutzer authentifiziert. + user = deps.get_store().get_user_by_token(raw) + assert user is not None + assert user.external_id == "dschlueter" + + +def test_admin_login_rotates_token(forward_auth): + r1 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw1 = r1.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + r2 = client.get("/api/admin/login", headers={HEADER: "dschlueter"}, + follow_redirects=False) + raw2 = r2.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + assert raw1 != raw2 + # Der alte Token ist nach der Rotation ungueltig. + assert deps.get_store().get_user_by_token(raw1) is None + assert deps.get_store().get_user_by_token(raw2) is not None + + +def test_admin_login_rejects_non_admin(forward_auth): + r = client.get("/api/admin/login", headers={HEADER: "lieschen"}, + follow_redirects=False) + assert r.status_code == 403 + + +def test_admin_login_rejects_without_identity(forward_auth): + # Vertrauter Proxy, aber kein Identitaets-Header -> keine Admin-Identitaet -> 403. + # (In Produktion faengt nginx das vorher mit dem Authelia-Redirect ab.) + r = client.get("/api/admin/login", follow_redirects=False) + assert r.status_code == 403 + + +# --- /api/login (1FA-Passwort-Einstieg, auch fuer Nicht-Admins) --- + +def test_app_login_non_admin_gets_cookie(forward_auth): + # Normaler Authelia-Nutzer (kein Admin) bekommt trotzdem ein gueltiges Cookie. + r = client.get("/api/login", headers={HEADER: "lieschen"}, follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + raw = r.headers["set-cookie"].split(f"{CAPABILITY_COOKIE}=", 1)[1].split(";", 1)[0] + user = deps.get_store().get_user_by_token(raw) + assert user is not None and user.external_id == "lieschen" + + +def test_app_login_admin_also_works(forward_auth): + r = client.get("/api/login", headers={HEADER: "dschlueter"}, follow_redirects=False) + assert r.status_code == 303 + assert f"{CAPABILITY_COOKIE}=" in r.headers.get("set-cookie", "") + + +def test_app_login_rejects_without_identity(forward_auth): + r = client.get("/api/login", follow_redirects=False) + assert r.status_code == 403 diff --git a/tests/test_emergency_cooldown.py b/tests/test_emergency_cooldown.py new file mode 100644 index 0000000..84b95ac --- /dev/null +++ b/tests/test_emergency_cooldown.py @@ -0,0 +1,133 @@ +"""Wiederauslöse-Sperre (Cooldown) für den manuellen Notruf. + +Nach einem Alarm ist ein erneuter Alarm für emergency_cooldown_minutes blockiert +(Schutz vor Mehrfach-Alarmen durch verwirrte Senioren). +""" +import asyncio +from datetime import datetime, timezone, timedelta + +import pytest +from fastapi.testclient import TestClient + +import app.safety.emergency as em +from app.config import settings +from app.main import app + +client = TestClient(app) + + +class FakeUser: + id = "u-cooldown" + display_name = "Test" + prefs: dict = {} + + +class FakeStore: + def __init__(self, last=None): + self._last = last + self.logged = 0 + + def log_emergency(self, *a, **k): + self.logged += 1 + self._last = datetime.now(timezone.utc).isoformat() + + def last_manual_emergency_at(self, user_id): + return self._last + + +def _iso_ago(seconds: int) -> str: + return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat() + + +def test_status_active_within_window(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(60))) + assert s["active"] is True + assert s["minutes_ago"] == 1 + assert 0 < s["remaining_seconds"] <= 300 + + +def test_status_expired(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(600))) + assert s["active"] is False + assert s["remaining_seconds"] == 0 + + +def test_status_disabled_when_zero(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 0) + s = em.emergency_cooldown_status(FakeUser(), FakeStore(last=_iso_ago(1))) + assert s["active"] is False + + +def test_second_alarm_suppressed(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: True) # falls doch versucht + store = FakeStore(last=_iso_ago(30)) + res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de")) + assert res["status"] == "cooldown" + assert res["minutes_ago"] == 0 + assert store.logged == 0 # KEIN neuer Eintrag, KEIN zweiter Alarm + + +def test_first_alarm_goes_through(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + monkeypatch.setattr(em, "_send_email_sync", lambda *a, **k: False) + monkeypatch.setattr(em.settings, "emergency_webhook_url", "") + store = FakeStore(last=None) + res = asyncio.run(em.record_manual_emergency(FakeUser(), store, language="de")) + assert res["status"] == "alerted" + assert res["cooldown_seconds"] == 300 + assert store.logged == 1 + + +def test_status_endpoint(monkeypatch): + # Endpunkt-Verdrahtung: nach einem Alarm meldet /status active=True. + monkeypatch.setattr(settings, "emergency_cooldown_minutes", 5) + client.post("/api/emergency", json={"language": "de"}) + st = client.get("/api/emergency/status").json() + assert st["active"] is True + assert st["cooldown_seconds"] == 300 + + +def _user_with(prefs): + u = FakeUser() + u.prefs = prefs + return u + + +def test_per_user_override(monkeypatch): + # Nutzer-Pref (2 Min) schlägt die globale Einstellung (5 Min). + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": "2"}), + FakeStore(last=_iso_ago(60))) + assert s["cooldown_seconds"] == 120 + assert s["active"] is True # 60s < 120s + + +def test_per_user_empty_inherits_global(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": ""}), + FakeStore(last=_iso_ago(60))) + assert s["cooldown_seconds"] == 300 # globaler Standard + + +def test_per_user_zero_disables(monkeypatch): + monkeypatch.setattr(em.settings, "emergency_cooldown_minutes", 5) + s = em.emergency_cooldown_status(_user_with({"emergency_cooldown_minutes": "0"}), + FakeStore(last=_iso_ago(5))) + assert s["active"] is False + assert s["cooldown_seconds"] == 0 + + +def test_schema_validates_cooldown(): + from app.schemas import AdminUserPrefsUpdate + # gültig: leer (erben), "0" (aus), kleine Zahl + assert AdminUserPrefsUpdate(emergency_cooldown_minutes="").emergency_cooldown_minutes == "" + assert AdminUserPrefsUpdate(emergency_cooldown_minutes="0").emergency_cooldown_minutes == "0" + assert AdminUserPrefsUpdate(emergency_cooldown_minutes="10").emergency_cooldown_minutes == "10" + # ungültig: > 60 und nicht-numerisch + with pytest.raises(ValueError): + AdminUserPrefsUpdate(emergency_cooldown_minutes="61") + with pytest.raises(ValueError): + AdminUserPrefsUpdate(emergency_cooldown_minutes="abc") diff --git a/tests/test_emergency_enrichment.py b/tests/test_emergency_enrichment.py index 32d60e2..db8d90f 100644 --- a/tests/test_emergency_enrichment.py +++ b/tests/test_emergency_enrichment.py @@ -99,6 +99,9 @@ def test_record_manual_emergency_uses_profile(monkeypatch): def log_emergency(self, *a, **k): pass + def last_manual_emergency_at(self, user_id): + return None # kein vorheriger Notruf -> keine Sperre aktiv + res = asyncio.run( em.record_manual_emergency(FakeUser(FULL_PREFS), DummyStore(), language="de", geo=GEO) ) diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 88bd0c9..5093495 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -51,7 +51,9 @@ def test_manual_emergency_logs_and_returns_localized_notice(): assert any(e["category"] == "manual" for e in events) -def test_manual_emergency_notice_language_fallback(): +def test_manual_emergency_notice_language_fallback(monkeypatch): + # Sperre aus, damit beide Auslösungen unabhängig durchgehen (nicht unterdrückt). + monkeypatch.setattr(settings, "emergency_cooldown_minutes", 0) # Unbekannte Sprache -> deutscher Fallback-Hinweis en = client.post("/api/emergency", json={"language": "en"}).json()["notice"] assert "ATTENTION" in en