diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 14c70eb..a703ed3 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.6 Anmeldung & Admin-Zugang](#76-anmeldung--admin-zugang-link-passwort-recovery) +7. [Nutzerverwaltung und Authentifizierung](#7-nutzerverwaltung-und-authentifizierung) · [7.5 Admin-Web-Panel](#75-admin-web-panel) 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,13 +1798,6 @@ 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. @@ -1872,86 +1865,6 @@ 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 fb640bf..22db3eb 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, RedirectResponse +from fastapi.responses import FileResponse 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 CAPABILITY_COOKIE, is_admin_user, require_admin, require_admin_or_user +from app.auth import 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,50 +66,6 @@ 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.""" @@ -221,7 +177,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_cooldown_minutes", + "emergency_contacts", "emergency_phones", )}, "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 8098f60..26ac77b 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -17,8 +17,7 @@ from app.dependencies import ( ) from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError -from app.runtime_config import runtime_settings -from app.safety.emergency import record_manual_emergency, emergency_cooldown_status +from app.safety.emergency import record_manual_emergency from app.schemas import ChatRequest, EmergencyRequest router = APIRouter() @@ -154,12 +153,4 @@ 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 4f8c6cb..4c2ffc1 100644 --- a/app/config.py +++ b/app/config.py @@ -206,10 +206,6 @@ 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 af2e6ac..cff898a 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -1,5 +1,3 @@ -import asyncio - from app.schemas import AudioChunk, PipelineTrace from app.pipeline.sentence_chunker import SentenceChunker from app.metrics import timer, metrics @@ -199,92 +197,49 @@ class Orchestrator: parts: list[str] = [] stream_fn = getattr(self.llm, "stream", None) - - # 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() - - 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) - - if queue is not None: - consumer_task = asyncio.create_task(_consume()) - - 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 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(result) + await on_token(delta) if chunker: - for sentence in chunker.feed(result): - await _dispatch(sentence) - - 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"" - + 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: - 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 + for sentence in chunker.feed(result): + await _emit_sentence(sentence) + + 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 _emit_sentence(tail) + audio = b"".join(audio_parts) + else: + audio = await self.tts.synthesize( + trace.tts_ready_text, voice=voice, language=effective_language + ) await self._emit_to_output(audio, output) return trace, audio diff --git a/app/main.py b/app/main.py index 656a04e..914c9f2 100644 --- a/app/main.py +++ b/app/main.py @@ -75,14 +75,11 @@ _NO_ACCESS_PAGE = """ +p{color:#475569;line-height:1.6;margin:.5rem 0}.emoji{font-size:2.5rem}
🔒

Persönlicher Zugang nötig

-

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 +

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

+

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

""" diff --git a/app/runtime_config.py b/app/runtime_config.py index aaef3ea..4f67f48 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -33,7 +33,6 @@ 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 1a0d7fb..70f5062 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -270,74 +270,10 @@ 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. - - 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), - } - + """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.""" # 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"}) @@ -366,8 +302,6 @@ 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 ec6ab5f..ac7f96c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -174,9 +174,6 @@ 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 @@ -213,18 +210,6 @@ 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 c3252e7..8e3d9be 100644 --- a/app/store.py +++ b/app/store.py @@ -148,9 +148,6 @@ 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]: ... @@ -564,17 +561,6 @@ 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 93783f9..7371de8 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -368,7 +368,6 @@ 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. @@ -423,7 +422,6 @@ 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"; @@ -842,9 +840,6 @@ 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", () => @@ -1192,13 +1187,6 @@ function buildUserCard(u) { - -

leer = globaler Standard · 0 = aus

@@ -1249,8 +1237,7 @@ 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_cooldown_minutes"]; + "emergency_contacts", "emergency_phones"]; PROF_KEYS.forEach((k) => { const el = card.querySelector(`.prof-f[data-k="${k}"]`); if (el) el.value = u[k] || ""; @@ -2258,145 +2245,29 @@ function deviceInfo() { return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) }; } -// 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; - 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 + emergencyBtn.addEventListener("click", async () => { + if (!confirm("Wirklich Hilfe rufen?\nIch alarmiere sofort Helfer!")) return; + const lang = (langSel && langSel.value) || "de"; + 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; + } }); } -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 27510af..f1c782c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -229,7 +229,7 @@

Voice Assistant

+ class="shrink-0 h-9 w-9 grid place-items-center rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-base">🆘 - - @@ -372,45 +365,6 @@
- - - - - - - + diff --git a/tests/test_admin_app_login.py b/tests/test_admin_app_login.py deleted file mode 100644 index 3047e3d..0000000 --- a/tests/test_admin_app_login.py +++ /dev/null @@ -1,96 +0,0 @@ -"""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 deleted file mode 100644 index 84b95ac..0000000 --- a/tests/test_emergency_cooldown.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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 db8d90f..32d60e2 100644 --- a/tests/test_emergency_enrichment.py +++ b/tests/test_emergency_enrichment.py @@ -99,9 +99,6 @@ 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 5093495..88bd0c9 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -51,9 +51,7 @@ 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(monkeypatch): - # Sperre aus, damit beide Auslösungen unabhängig durchgehen (nicht unterdrückt). - monkeypatch.setattr(settings, "emergency_cooldown_minutes", 0) +def test_manual_emergency_notice_language_fallback(): # Unbekannte Sprache -> deutscher Fallback-Hinweis en = client.post("/api/emergency", json={"language": "en"}).json()["notice"] assert "ATTENTION" in en