From 1af3a6e90a062d5348f6715af7439615332d8cbe Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 19:25:22 +0100 Subject: [PATCH] feat(auth): server-side sessions and current password for account changes (FLAW-30, FLAW-31) - Every login is a session (device, IP, last use); the token's jti is its ID. Sessions end on logout, per device from the profile, or after 30 idle days. - Tokens older than a day are renewed via the X-Session-Token header. - Changing password or login e-mail needs the current password; a new password logs out all other devices and mails the old address. - Demo reset keeps the demo visitors' sessions. - revoked_tokens is replaced by sessions (migration 4); everyone is logged out once after the upgrade. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + Doku/README.md | 2 +- Doku/api/rest.md | 6 +- Doku/architecture.md | 4 +- Doku/database.md | 2 +- Doku/flaws.md | 7 +- Doku/sharing-and-permissions.md | 2 +- .../src/features/settings/SessionsPanel.tsx | 110 +++++++++++++ .../features/settings/UserSettingsView.tsx | 28 +++- .../settings/__tests__/describeDevice.test.ts | 24 +++ frontend/src/lib/api.ts | 14 ++ frontend/src/lib/types.ts | 10 ++ internal/api/handlers_auth.go | 91 ++++++++--- internal/api/realtime.go | 2 +- internal/api/realtime_test.go | 2 +- internal/api/server.go | 16 +- internal/api/server_test.go | 26 +++- internal/api/sessions.go | 78 ++++++++++ internal/api/sessions_test.go | 144 ++++++++++++++++++ internal/api/streamticket.go | 7 +- internal/auth/auth.go | 13 +- internal/storage/demo.go | 25 +++ internal/storage/migrations.go | 1 + internal/storage/sessions.go | 127 +++++++++++++++ internal/storage/users.go | 31 +--- 25 files changed, 705 insertions(+), 75 deletions(-) create mode 100644 frontend/src/features/settings/SessionsPanel.tsx create mode 100644 frontend/src/features/settings/__tests__/describeDevice.test.ts create mode 100644 internal/api/sessions.go create mode 100644 internal/api/sessions_test.go create mode 100644 internal/storage/sessions.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e89cd76..cfb5922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ### 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). ### Fixed - 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. +### 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}`. + ### Changed +- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt). +- **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. 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..cdec8c7 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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) | diff --git a/Doku/architecture.md b/Doku/architecture.md index ec66e2d..3b04a71 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -78,8 +78,8 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in - **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`. +- **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. diff --git a/Doku/database.md b/Doku/database.md index eb618c5..772bfe7 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -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 1835a05..0763999 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -12,8 +12,8 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** |---|---|---|---| | 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 | Mittel | -| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel | +| 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 | Mittel | | FLAW-33 | Demo | Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen) | Mittel | | FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig | @@ -45,11 +45,13 @@ 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. @@ -131,7 +133,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/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..7b4ca83 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 { @@ -188,6 +189,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 +255,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 +279,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' }); @@ -634,10 +642,27 @@ export const UserSettingsView: React.FC = ({ value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="Mindestens 8 Zeichen" + autoComplete="new-password" className="w-full h-9 px-3 input-field" /> + {needsCurrentPassword && ( +
+ + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full h-9 px-3 input-field" + /> +
+ )} +