From ff27d07aa3209abb8bdd8c98320ed2f19675be2d Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 18:39:20 +0100 Subject: [PATCH 1/9] docs(flaws): FLAW-33 covers outgoing alarms of a public demo Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index aee8532..68fa865 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -15,7 +15,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **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-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 | | 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 | @@ -54,10 +54,11 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** - **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. -### 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. ### FLAW-34: SMTP-Passwort im Klartext - **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`. From 3a27a37acb7a811ef7f2fc225d4ea089149e3479 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 18:49:35 +0100 Subject: [PATCH 2/9] feat(triage): skip list selector when only one writable list exists --- .../features/capture/InstantCaptureBar.tsx | 19 +- frontend/src/features/triage/TriageDeck.tsx | 71 ++++++-- .../__tests__/TriageDeck.lists.test.tsx | 163 ++++++++++++++++++ 3 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 frontend/src/features/triage/__tests__/TriageDeck.lists.test.tsx diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index cffc170..4097356 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -38,6 +38,7 @@ export const InstantCaptureBar: React.FC = ({ hideTargetPicker = false, fixedTarget, }) => { + const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer'); const [content, setContent] = useState(''); const [priority, setPriority] = useState('normal'); const [dueDate, setDueDate] = useState(''); @@ -77,10 +78,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 +181,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 +237,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 +450,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 +486,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 ( @@ -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) => (
- 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/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index f56c330..84d6c92 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -25,6 +25,7 @@ import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; import { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; +import { safeHref } from '../../lib/safeUrl'; export interface SlashCommand { type: BlockType | 'link'; @@ -1001,9 +1002,9 @@ export const OutlinerBlock: React.FC = ({ ); } const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/); - if (mdMatch) { + if (mdMatch && safeHref(mdMatch[2])) { const label = mdMatch[1]; - const url = mdMatch[2]; + const url = safeHref(mdMatch[2]) as string; return ( = ({ ); } - if (/^https?:\/\//.test(part)) { + if (/^https?:\/\//.test(part) && safeHref(part)) { return ( { + it('keeps web and mail links', () => { + expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); + expect(safeHref(' http://example.com ')).toBe('http://example.com/'); + expect(safeHref('mailto:anna@example.com')).toBe('mailto:anna@example.com'); + }); + + it('drops everything that could run code or load local data', () => { + for (const bad of [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + ' javascript:alert(1)', + 'java\tscript:alert(1)', + 'java\nscript:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + '/relative/path', + '', + ]) { + expect(safeHref(bad)).toBeNull(); + } + }); +}); + +describe('Markdown links in task notes', () => { + it('render javascript: links as text, web links as links', async () => { + const { render } = await import('@testing-library/react'); + const { MarkdownViewer } = await import('../markdown'); + const { container } = render(); + const links = Array.from(container.querySelectorAll('a')); + expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']); + expect(container.textContent).toContain('Rechnung'); + }); +}); diff --git a/frontend/src/lib/markdown.tsx b/frontend/src/lib/markdown.tsx index 9f7471f..091a16a 100644 --- a/frontend/src/lib/markdown.tsx +++ b/frontend/src/lib/markdown.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { safeHref } from './safeUrl'; /** * Parses inline markdown: @@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac {token.slice(2, -2)} ); - } else if (token.startsWith('[') && match[2] && match[3]) { + } else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) { const label = match[2]; - const url = match[3]; + const url = safeHref(match[3]) as string; elements.push( ); + +// Service worker for the app shell (offline start, see public/sw.js) +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} diff --git a/internal/api/headers.go b/internal/api/headers.go new file mode 100644 index 0000000..c169f3e --- /dev/null +++ b/internal/api/headers.go @@ -0,0 +1,48 @@ +package api + +import ( + "net/http" + "strings" +) + +// securityHeaders protects the browser side (FLAW-29): +// - Content-Security-Policy: scripts only from MindSift itself, so injected markup cannot run code; +// styles may be inline (custom themes, style attributes); images also from https (avatar URLs); +// connections only to this server, including its WebSocket. +// - frame-ancestors 'none' / X-Frame-Options: no embedding in other sites (clickjacking). +// - nosniff, a same-origin referrer and a permissions policy that only allows the microphone +// (speech input) for MindSift itself. +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy(r.Host)) + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=(), payment=()") + next.ServeHTTP(w, r) + }) +} + +func contentSecurityPolicy(host string) string { + connect := "'self'" + // Older Safari versions do not count ws:/wss: as 'self' + if host != "" && !strings.ContainsAny(host, " ;'\"") { + connect += " wss://" + host + " ws://" + host + } + return strings.Join([]string{ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src " + connect, + "worker-src 'self'", + "manifest-src 'self'", + "media-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + }, "; ") +} diff --git a/internal/api/headers_test.go b/internal/api/headers_test.go new file mode 100644 index 0000000..a3a5310 --- /dev/null +++ b/internal/api/headers_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSecurityHeaders(t *testing.T) { + srv, _, _ := setupTestServer(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + req.Host = "notes.example.com" + srv.Router().ServeHTTP(rec, req) + csp := rec.Header().Get("Content-Security-Policy") + for _, want := range []string{"script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "wss://notes.example.com"} { + if !strings.Contains(csp, want) { + t.Errorf("CSP lacks %q: %s", want, csp) + } + } + if strings.Contains(csp, "unsafe-eval") || strings.Contains(strings.Split(strings.Split(csp, "script-src")[1], ";")[0], "unsafe-inline") { + t.Errorf("scripts must not allow inline or eval: %s", csp) + } + for k, v := range map[string]string{"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"} { + if rec.Header().Get(k) != v { + t.Errorf("%s = %q, want %q", k, rec.Header().Get(k), v) + } + } + // A Host header with quotes or semicolons must not be able to extend the policy + if p := contentSecurityPolicy("evil.com; script-src *"); strings.Contains(p, "evil.com") { + t.Errorf("host must not be copied into the policy unchecked: %s", p) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index cdffec0..1b5422b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(s.clientIPMiddleware) + r.Use(s.securityHeaders) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) From 1af3a6e90a062d5348f6715af7439615332d8cbe Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 19:25:22 +0100 Subject: [PATCH 5/9] 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" + /> +
+ )} +
-
+
+
+
+

+ 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/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 7b4ca83..22ae1a9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -97,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 --- @@ -628,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" />
@@ -641,9 +651,10 @@ export const UserSettingsView: React.FC = ({ type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - placeholder="Mindestens 8 Zeichen" + placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'} autoComplete="new-password" - className="w-full h-9 px-3 input-field" + disabled={demoMode} + className="w-full h-9 px-3 input-field disabled:opacity-60" /> @@ -693,8 +704,9 @@ export const UserSettingsView: React.FC = ({ @@ -735,7 +747,8 @@ export const UserSettingsView: React.FC = ({