Compare commits
No files matched your search
@@ -18,8 +18,18 @@ BASE_URL=http://localhost:8080
|
||||
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
|
||||
# Lässt sich später in Administration → Server & Isolation ändern.
|
||||
# Registrierung ohne Einladung. Ohne Angabe entscheidet Administration → Server & Isolation.
|
||||
# Gesetzt ist es dort gesperrt:
|
||||
# closed nur mit Einladung
|
||||
# approval jeder darf sich registrieren, ein Admin schaltet frei
|
||||
# domain nur E-Mail-Adressen aus REGISTRATION_DOMAINS, sofort aktiv
|
||||
# domain+approval diese Endungen sofort, alle anderen nach Freigabe
|
||||
# open jeder, sofort aktiv
|
||||
# REGISTRATION_MODE=approval
|
||||
# REGISTRATION_DOMAINS=firma.de, familie.de
|
||||
# Neue Konten ohne Einladung bestätigen zuerst ihre E-Mail-Adresse per Link (braucht SMTP und BASE_URL):
|
||||
# REGISTRATION_VERIFY_EMAIL=true
|
||||
# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin.
|
||||
ALLOW_REGISTRATION=false
|
||||
|
||||
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Hinweise für Coding-Agenten
|
||||
|
||||
Regeln für die Arbeit an MindSift, die nicht aus dem Code selbst hervorgehen. Aufbau und Technik stehen in [Doku/](Doku/README.md), vor allem [architecture.md](Doku/architecture.md).
|
||||
|
||||
## Sprache und Stil
|
||||
|
||||
- **Deutsch:** Oberflächentexte, Fehlermeldungen für Nutzer, E-Mails, `CHANGELOG.md`, `README.md`, `Doku/`.
|
||||
- **Englisch:** Code-Kommentare und Commit-Nachrichten.
|
||||
- **Commit-Nachrichten** im Stil `feat(bereich): …`, `fix(bereich): …`, `docs: …`, `style(bereich): …`. Am Ende die Zeile `Co-Authored-By: …` des jeweiligen Agenten.
|
||||
- Neuer Code richtet sich nach dem umgebenden Code: Benennung, Kommentardichte, vorhandene Helfer statt neuer Varianten.
|
||||
|
||||
## Umgebung
|
||||
|
||||
Auf dem Entwicklungsrechner sind weder Node noch Go installiert. Alles läuft in Containern (podman):
|
||||
|
||||
```bash
|
||||
# Frontend: Typen, Lint, Tests
|
||||
podman run --rm -v "$PWD/frontend":/app:Z -w /app docker.io/library/node:22-alpine \
|
||||
sh -c "npx tsc --noEmit && npx eslint src/… && npx vitest run"
|
||||
|
||||
# Backend: vet und Tests
|
||||
podman run --rm -v "$PWD":/src:Z -w /src docker.io/library/golang:1.24-alpine \
|
||||
sh -c "go vet ./... && go test ./internal/..."
|
||||
```
|
||||
|
||||
Die Linter-Warnungen `react-hooks/exhaustive-deps` gibt es schon länger; neuer Code soll keine zusätzlichen Warnungen bringen.
|
||||
|
||||
## Prüfen während der Arbeit
|
||||
|
||||
- **Gezielt prüfen:** `tsc`, die betroffenen Frontend-Tests, `go vet` und `go test`. Den vollen Build macht nur der Server (siehe Release-Ablauf); ein kompletter `podman build .` vorher ist doppelte Arbeit.
|
||||
- **Oberfläche im Browser ansehen**, besonders Handy-Ansichten: Image bauen, im Demo-Modus starten, mit Playwright aus dem `e2e/`-Ordner prüfen (Vorbild: `e2e/screenshots.sh`). Handy-Emulation mit `isMobile: true, hasTouch: true`; Finger-Gesten lassen sich über das Chrome DevTools Protocol (`Input.dispatchTouchEvent`) nachstellen.
|
||||
|
||||
```bash
|
||||
podman build -q -t mindsift-check .
|
||||
podman run -d --rm --name ms-check --network host -e PORT=18555 -e MINDA_DEMO=true \
|
||||
-e DEMO_RESET_MINUTES=0 -e JWT_SECRET=check-only mindsift-check
|
||||
# Demo-Anmeldung: admin/admin bzw. user/user
|
||||
```
|
||||
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
|
||||
|
||||
## Screenshots
|
||||
|
||||
- Bilder in `screenshots/` (README) sind **WebP**, keine PNG: Desktop und Theme-Raster 1280 px breit, Handy 480 px, Qualität 90. Das bringt rund ein Fünftel der PNG-Größe bei sauberer Schrift.
|
||||
- Erzeugt werden sie mit `e2e/screenshots.sh`; das Skript nimmt PNGs auf und wandelt sie am Ende mit `cwebp` (Alpine-Container) um. Einzelne Bilder neu aufnehmen: denselben Ablauf aus `screenshots.mjs` verwenden und mit dem Umwandlungsschritt aus `screenshots.sh` konvertieren.
|
||||
- Chromiums eigenes WebP (`canvas.toDataURL('image/webp')`) nicht verwenden: verlustfrei wird es größer als die PNG.
|
||||
|
||||
## Release-Ablauf
|
||||
|
||||
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
|
||||
|
||||
1. **Version erhöhen** (SemVer: Fehlerbehebung → Patch, neue Funktion → Minor):
|
||||
- `frontend/package.json` (Feld `version`)
|
||||
- `frontend/package-lock.json`: nur die beiden Versionszeilen von MindSift selbst (Zeile 3 und 9), nicht die gleichlautenden Versionen von Abhängigkeiten
|
||||
- Versions-Badge oben in `README.md`
|
||||
2. **CHANGELOG:** neuer Abschnitt `## [x.y.z] - JJJJ-MM-TT` unter `## [Unreleased]` (Format *Keep a Changelog*, Abschnitte Added/Changed/Fixed), unten die Vergleichslinks `[Unreleased]` und `[x.y.z]` anpassen. Einträge auf Deutsch und aus Sicht der Nutzer.
|
||||
3. **Doku nachziehen**, wenn sich Verhalten oder API ändern (`README.md`, `Doku/api/rest.md`, `Doku/architecture.md`).
|
||||
4. Committen, dann **nur nach `dev` pushen**.
|
||||
5. **Warten, bis der `dev`-Build auf dem Server grün ist.** Schlägt er fehl: auf `dev` beheben, nicht nach `main` mergen.
|
||||
6. Nach `main` mergen (`git merge --no-ff dev -m "Merge dev into main (vx.y.z)"`) und pushen.
|
||||
7. **Warten, bis der `main`-Build grün ist.** Erst dann gilt das Release als draußen.
|
||||
|
||||
Ist ein Release nie gebaut worden (CI fehlgeschlagen), bleibt die Versionsnummer, und die Korrektur kommt in denselben CHANGELOG-Abschnitt.
|
||||
|
||||
**Git-Tags nur auf ausdrücklichen Wunsch.** Tags lassen zusätzliche Images bauen; genutzt werden nur `:dev` und `:latest`. Wenn gewünscht: `git tag -a vx.y.z -m "MindSift vx.y.z"` auf den Merge-Commit in `main` und den Tag pushen.
|
||||
|
||||
Fremde Branches (z. B. von anderen Agenten) werden vor dem Mergen geprüft.
|
||||
|
||||
### CI auf dem Git-Server
|
||||
|
||||
- Workflow: `.gitea/workflows/build-and-publish.yaml`, Entscheidung in `.gitea/scripts/build-plan.sh`.
|
||||
- `dev` → Image `:dev`, mit allen Tests (Dockerfile: tsc, Lint, Frontend-Tests, `go vet`, Go-Tests).
|
||||
- `main` → Image `:latest` mit `APP_VERSION=v<package.json-Version>`; überspringt die Tests, wenn `:dev` aus demselben Code gebaut wurde. Deshalb erst nach grünem `dev`-Build mergen.
|
||||
- Tag `v*` → `:vx.y.z` und `:stable`.
|
||||
- Status der letzten Läufe:
|
||||
|
||||
```bash
|
||||
curl -s "https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks?limit=4"
|
||||
# workflow_runs[].head_branch / .status (success|failure|…) / .display_title
|
||||
```
|
||||
|
||||
## Oberfläche
|
||||
|
||||
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
|
||||
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher.
|
||||
- **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`).
|
||||
- **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus.
|
||||
- **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen.
|
||||
@@ -6,6 +6,101 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.28.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
- KI-Agenten anbinden (MCP): MindSift ist jetzt ein MCP-Server unter `POST /api/v1/mcp`. Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können offene Aufgaben auslesen, abhaken, anlegen und ändern sowie Einkaufs- und Checklisten lesen, ergänzen und abhaken. Freigaben gelten wie für dich selbst; Notizen bleiben für Agenten unerreichbar.
|
||||
- API-Keys haben dafür neue Berechtigungen: Aufgaben lesen / ändern, Listen lesen / ändern. Der Agent sieht nur die Werkzeuge, für die sein Key freigegeben ist. Die Einstellungen zeigen den fertigen Befehl für Claude Code, die `config.toml` für Codex und die JSON-Konfiguration für Claude Desktop und Cursor.
|
||||
|
||||
### Changed
|
||||
- Einstellungen: Die Berechtigungen eines API-Keys und die Bereiche beim Import und Wiederherstellen (Notizen, Aufgaben, Listen, Inbox, Themes) sind jetzt Schalter wie im Rest der Einstellungen statt anklickbarer Buttons mit Haken.
|
||||
- Der Einstellungsbereich heißt jetzt „Webhooks, KI-Agenten & Export“.
|
||||
|
||||
## [0.27.1] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Notizen am Handy: Die Leiste zum Ein- und Ausrücken über der Tastatur blieb stehen, wenn man die Tastatur schloss (z. B. mit der Zurück-Taste), und verdeckte die untere Navigation. Sie erscheint jetzt nur, solange die Tastatur offen ist; ein Tipp in den Punkt holt beides zurück.
|
||||
|
||||
## [0.27.0] - 2026-10-31
|
||||
|
||||
### Changed
|
||||
- Demo: Wer die Demo zum ersten Mal öffnet, sieht sie im runden Theme Fjord Nacht statt im eckigen Standard. Ein selbst gewähltes Theme bleibt erhalten; echte Installationen sind nicht betroffen.
|
||||
|
||||
## [0.26.1] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Notizen am Handy: Ein Tipp rechts neben einen Punkt löste die unsichtbaren Knöpfe aus (In eigene Notiz umwandeln, Fokus, Löschen …). Die Knöpfe reagieren jetzt nur noch, wenn sie zu sehen sind, also beim Bearbeiten des Punkts.
|
||||
- Notizen: Ein Punkt, der schon in eine eigene Notiz umgewandelt wurde (nur noch der Link, ggf. mit Hashtags), lässt sich nicht noch einmal umwandeln; der Knopf und `/extract` fehlen dort. Bisher entstand dabei eine zweite Notiz, die nur den Link enthielt.
|
||||
- Hinweis nach dem Umwandeln am Handy („Öffnen“, „Rückgängig“): sitzt jetzt über der unteren Leiste statt am Bildschirmrand, Text oben (höchstens zwei Zeilen), Knöpfe darunter.
|
||||
|
||||
## [0.26.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
- Verbindungsstatus im Kopf: Ist der Server nicht erreichbar, steht am Desktop unter deinem Namen klein „● offline“ mit rotem Punkt; die Seite bleibt bedienbar. Am Handy zeigt ein kleiner Punkt unten rechts am Profilbild den Status (grün online, rot offline). Das funktioniert auch, wenn nur der Server ausgefallen ist und das Gerät selbst noch Netz hat – bisher war das nur in den Listen zu sehen.
|
||||
|
||||
### Changed
|
||||
- Screenshots im README sind jetzt kleinere WebP-Bilder (zusammen etwa 0,5 statt 2,7 MB); das Journal-Bild zeigt die aktuelle Ansicht.
|
||||
|
||||
## [0.25.1] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Tafeln von unten am Handy (neuer Listeneintrag, neue Notiz, neue Liste, neuer Ordner …): Bei runden Ecken waren sie auch unten abgerundet und hatten einen Rahmen rundherum, sodass sie nicht sauber an der Tastatur anlagen. Jetzt sind sie wie die übrigen Tafeln nur oben rund und unten eckig; am Desktop bleibt das Fenster rundum abgerundet.
|
||||
|
||||
## [0.25.0] - 2026-10-31
|
||||
|
||||
### Changed
|
||||
- Einkaufslisten am Handy: Das Eingabefeld steht nicht mehr dauerhaft über der Liste. „+“ öffnet es als Tafel von unten (wie bei Aufgaben und Triage); nach „Hinzufügen“ bleibt sie offen und leer für den nächsten Eintrag. Am Desktop bleibt das Feld wie bisher.
|
||||
- Notizen am Handy: „+“ (und „Neue Notiz“ im Menü) fragt zuerst Name und Emoji ab; erst „Erstellen“ legt die Notiz an. Ein versehentlicher Tipp hinterlässt so keine leere Notiz mehr. Am Desktop entsteht die Notiz wie bisher sofort.
|
||||
|
||||
## [0.24.2] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Triage am Handy: „Auf Liste“ (bei mehreren Listen) und „Mit Termin“ taten nichts, weil ihre Auswahl nur am Desktop eingeblendet wurde. Am Handy öffnen sie jetzt das große Auswahlfenster von unten (wie beim langen Drücken auf die Karte).
|
||||
|
||||
### Added
|
||||
- `TODO.md` mit offenen und zurückgestellten Aufgaben im Repository.
|
||||
|
||||
## [0.24.1] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Einkaufslisten offline: Neue Einträge ohne Verbindung meldeten „keine Verbindung zum Server“. Jetzt erscheinen sie sofort in der Liste (mit kleinem Wolken-Symbol), bleiben auch nach einem Neuladen sichtbar und werden gesendet, sobald die Verbindung zurück ist. Abhaken, Löschen oder Menge ändern, bevor sie gesendet wurden, wirkt direkt auf den wartenden Eintrag.
|
||||
|
||||
### Changed
|
||||
- Einkaufslisten: Das Feld für neue Einträge ist jetzt als Eingabefeld erkennbar (Rahmen, Plus im Feld) und nimmt den meisten Platz ein; Menge und „Hinzufügen“ sind gleich hoch daneben bzw. am Handy darunter.
|
||||
|
||||
## [0.24.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
- **E-Mail-Adresse bestätigen:** Neue Einstellung (Administration → Server & Isolation, oder `REGISTRATION_VERIFY_EMAIL=true`). Wer sich ohne Einladung registriert, bekommt einen Link per E-Mail und ist erst danach aktiv; in den Freigabe-Modi geht das Konto danach an die Admins, die auch erst dann benachrichtigt werden. Beim Anmelden vor der Bestätigung gibt es „Link erneut senden“. Unbestätigte Konten werden nach 48 Stunden gelöscht, die Adresse ist dann wieder frei. Wirkt nur mit Mail-Server und `BASE_URL`; Einladungen brauchen keine Bestätigung.
|
||||
|
||||
### Changed
|
||||
- Einladung erzeugen: Die E-Mail-Adresse steht vorne und hat mehr Platz, „Wie oft verwendbar“ ist schmal.
|
||||
|
||||
## [0.23.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
- **Einladungslinks:** Jeder Einladungscode ist auch ein Link (`/?invite=CODE`), der die Registrierung mit eingetragenem Code öffnet. In der Administration gibt es dafür „Link kopieren“ und – mit Mail-Server – „Per E-Mail“ (Einladung im MindSift-Layout mit Knopf „Konto anlegen“; einmalige Codes werden dabei an die Adresse gebunden).
|
||||
- **Ein Link für mehrere Personen:** Beim Erzeugen „Wie oft verwendbar“ angeben (z. B. 20); die Liste zeigt „3 von 20 verwendet“. Auch gleichzeitige Registrierungen können einen Link nie öfter einlösen als erlaubt.
|
||||
- **Registrierung mit Freigabe und nach E-Mail-Endung:** Statt „offen/geschlossen“ gibt es fünf Modi – geschlossen, mit Freigabe durch einen Admin, nur bestimmte E-Mail-Endungen, Endungen sofort und alle anderen nach Freigabe, offen. Wartende Konten stehen in der Administration unter „Wartet auf Freigabe“ (Freigeben/Ablehnen); Admins bekommen eine E-Mail, die Person nach der Freigabe ebenfalls. Bis dahin ist keine Anmeldung möglich (mit Hinweis). Das Registrierungsformular erklärt, welche Adressen erlaubt sind.
|
||||
- Neue Umgebungsvariablen `REGISTRATION_MODE` und `REGISTRATION_DOMAINS`: Gesetzt gelten sie und sind in der Administration gesperrt. `ALLOW_REGISTRATION=true` entspricht `REGISTRATION_MODE=open`.
|
||||
|
||||
### Fixed
|
||||
- `ALLOW_REGISTRATION` wurde bisher gar nicht ausgewertet; die Registrierung ließ sich in der Administration unabhängig davon ein- und ausschalten.
|
||||
|
||||
## [0.22.2] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Spracheingabe über den Browser (ohne Whisper-Server): In Chrome auf Android wurde ein Satz mehrfach übernommen („TestTestTestTest 1 2 3“ statt „Test 1 2 3“), weil Android jeden Zwischenstand als eigenes Ergebnis liefert. Fortsetzungen ersetzen jetzt den vorherigen Stand.
|
||||
|
||||
### Added
|
||||
- `AGENTS.md` mit Regeln für Coding-Agenten (Sprache, Prüfen, Release-Ablauf über die CI) und ein Plan für Registrierung mit Freigabe/E-Mail-Endungen und Anmeldung per OpenID Connect (`Doku/plans/`).
|
||||
|
||||
## [0.22.1] - 2026-10-31
|
||||
|
||||
### Fixed
|
||||
- Push-Benachrichtigungen: Nach dem Neuinstallieren der App zählte die alte Installation als weiteres Gerät mit („2 aktiv“), bis der nächste Push sie aussortierte. Schaltet ein Gerät Push neu ein, meldet es jetzt seine alte Anmeldung mit ab. Außerdem zeigen die Einstellungen alle Geräte einzeln (mit Datum und „dieses Gerät“) und jedes lässt sich entfernen.
|
||||
- Tageszusammenfassung und Neuigkeiten per E-Mail: Die Begrüßung („Guten Morgen“) richtete sich nach der Uhr beim Versand statt nach der Zeit, für die die Nachricht gedacht ist. Dadurch schlug ab mittags auch ein Test fehl, und der Build von 0.22.1 brach ab – die Version kommt erst mit diesem Fix an.
|
||||
|
||||
## [0.22.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
@@ -459,7 +554,20 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...dev
|
||||
[0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0
|
||||
[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1
|
||||
[0.27.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...v0.27.0
|
||||
[0.26.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...v0.26.1
|
||||
[0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0
|
||||
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
|
||||
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
|
||||
[0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2
|
||||
[0.24.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.0...v0.24.1
|
||||
[0.24.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...v0.24.0
|
||||
[0.23.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...v0.23.0
|
||||
[0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2
|
||||
[0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1
|
||||
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
|
||||
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
|
||||
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
|
||||
|
||||
@@ -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`) 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).
|
||||
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 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). Mit den Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` nutzen sie außerdem den [MCP-Endpunkt](#mcp-für-ki-agenten-post-apiv1mcp). 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
|
||||
|
||||
@@ -11,7 +11,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||
| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
|
||||
| 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; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
||||
| POST | `/auth/verify-email` | Link aus der Bestätigungs-E-Mail (`token`): schaltet das Konto frei oder reicht es an die Freigabe weiter (`{"verified","pending","message"}`) |
|
||||
| POST | `/auth/resend-verification` | Bestätigungslink erneut schicken (`email`); antwortet immer gleich, höchstens alle 2 Minuten pro Konto |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); mit Code immer, sonst je nach Registrierungsmodus. `201` mit Token oder `202 {"pending":true,"verify","message"}`, wenn erst die E-Mail-Adresse bestätigt (`verify`) und/oder ein Admin freigeben muss (Login bis dahin `403`); `429` nach 20 Versuchen pro Adresse in 15 Minuten oder bei mehr als 50 wartenden Konten |
|
||||
| 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; `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 |
|
||||
@@ -47,7 +49,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| 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 | `/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 | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) |
|
||||
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
|
||||
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
@@ -75,6 +78,28 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||
}
|
||||
```
|
||||
|
||||
### MCP für KI-Agenten (`POST /api/v1/mcp`)
|
||||
|
||||
MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text.
|
||||
|
||||
| Werkzeug | Scope | Zweck |
|
||||
|---|---|---|
|
||||
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission` und offenen Aufgaben |
|
||||
| `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst |
|
||||
| `get_task` | `tasks:read` | Eine Aufgabe |
|
||||
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
||||
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
||||
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
||||
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
||||
| `get_list` | `lists:read` | Liste mit Einträgen |
|
||||
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
|
||||
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) |
|
||||
| `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern |
|
||||
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
|
||||
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
|
||||
|
||||
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
|
||||
|
||||
## Gedanken, Triage, Aufgaben
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
@@ -125,7 +150,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
||||
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
||||
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt; `400`, wenn der Punkt nur noch ein Link auf eine Notiz ist (ggf. mit Hashtags) |
|
||||
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
|
||||
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
|
||||
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
|
||||
@@ -211,9 +236,11 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
||||
| PUT | `/admin/users/{id}/organization` | Organisation setzen (`organizationId`, leer = keine); entzieht Freigaben, Gruppen und Zuweisungen der alten |
|
||||
| GET/POST | `/admin/organizations` | Organisationen mit `memberCount`; anlegen (`name`) |
|
||||
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen (bei wartenden Konten = ablehnen). `GET /admin/users` markiert wartende Konten mit `pendingApproval` |
|
||||
| POST | `/admin/users/{id}/approve` | Wartendes Konto freigeben; die Person bekommt eine E-Mail (`409`, wenn es nicht wartet) |
|
||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||
| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) |
|
||||
| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`, `maxUses` 1–1000; mehrfach verwendbare nicht an eine E-Mail gebunden). Antwort mit `maxUses`/`useCount`. Als Link: `/?invite=<CODE>` öffnet die Registrierung mit eingetragenem Code |
|
||||
| POST | `/admin/invites/{id}/send` | Einladung per E-Mail (`email`); bindet einmalige Codes an diese Adresse; braucht SMTP |
|
||||
| DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen |
|
||||
| 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`) |
|
||||
|
||||
@@ -63,9 +63,11 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `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 |
|
||||
| `REGISTRATION_MODE`, `REGISTRATION_DOMAINS` | leer | Registrierung ohne Einladung (`closed`, `approval`, `domain`, `domain+approval`, `open`) und erlaubte E-Mail-Endungen. Gesetzt gewinnen sie über `system_settings` und sind im Admin-UI gesperrt (`registrationPolicy` in `internal/api/registration.go`). Wartende Konten: `is_active = 0` plus Zeile in `pending_registrations` (Migration 10) |
|
||||
| `REGISTRATION_VERIFY_EMAIL` | leer | E-Mail-Bestätigung neuer Konten ohne Einladung (nur mit SMTP und `BASE_URL`). Token wie beim Passwort-Link nur als SHA-256 in `email_verifications` (Migration 11), 48 Stunden gültig; danach löscht die Verdichtung das unbestätigte Konto. Bei Freigabe-Modi erfahren Admins erst nach der Bestätigung davon |
|
||||
| `ALLOW_REGISTRATION` | `false` | `true` = `REGISTRATION_MODE=open`; `false` (Standard der Vorlagen) lässt die Wahl dem Admin-UI |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `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`) |
|
||||
| `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`). Wer auf einem Gerät noch kein Theme gewählt hat, sieht die Demo in Fjord Nacht (rund) statt im eckigen Standard (`DEMO_THEME` in `lib/theme.ts`) |
|
||||
| `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_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
|
||||
@@ -95,7 +97,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
||||
- **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`.
|
||||
- **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`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar.
|
||||
- **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.
|
||||
|
||||
@@ -160,6 +162,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
||||
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
|
||||
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||
- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
|
||||
|
||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
## Export als Markdown-Archiv
|
||||
|
||||
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||
|
||||
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
|
||||
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
|
||||
@@ -75,7 +75,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunter
|
||||
|
||||
## Import
|
||||
|
||||
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||
|
||||
| Weg | Endpunkt | Dateien | Ergebnis |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plan: Registrierung und Anmeldung (Entwurf)
|
||||
|
||||
Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). E-Mail-Bestätigung per Link in v0.24.0. OpenID Connect ist zurückgestellt (kein Bedarf), vermerkt in `TODO.md`. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung.
|
||||
|
||||
## Heute
|
||||
|
||||
- `ALLOW_REGISTRATION` bzw. Admin-Schalter „offene Registrierung“: jeder kann ein Konto anlegen.
|
||||
- Sonst nur mit Einladungscode (optional an eine E-Mail-Adresse und eine Organisation gebunden, einmal verwendbar, mit Ablaufdatum).
|
||||
- Anmeldung nur mit E-Mail/Benutzername und Passwort (dazu API-Keys für Webhooks).
|
||||
|
||||
## 1. Registrierungsmodus statt Ja/Nein
|
||||
|
||||
Ein Modus ersetzt den Schalter „offene Registrierung“:
|
||||
|
||||
| Modus | Verhalten |
|
||||
|---|---|
|
||||
| `closed` | Nur mit Einladungscode (wie heute ohne offene Registrierung). |
|
||||
| `approval` | Jeder kann sich registrieren, das Konto ist aber **gesperrt, bis ein Admin es freigibt**. |
|
||||
| `domain` | Registrierung nur mit E-Mail-Adressen bestimmter Endungen (z. B. `@firma.de, @familie-dach.de`), sofort aktiv. |
|
||||
| `domain+approval` | Erlaubte Endungen sofort aktiv, alle anderen warten auf Freigabe. |
|
||||
| `open` | Jeder, sofort aktiv (wie heute). |
|
||||
|
||||
Einladungscodes funktionieren in jedem Modus und überspringen Freigabe und Endungsprüfung.
|
||||
|
||||
**Freigabe durch Admin**
|
||||
- Neue Konten im Modus `approval` werden mit `is_active = 0` und einem Status „wartet auf Freigabe“ angelegt (eigenes Feld, damit „gesperrt“ und „wartet“ unterscheidbar bleiben).
|
||||
- Die Person sieht nach der Registrierung: „Dein Konto wird geprüft. Du bekommst eine E-Mail, sobald es freigeschaltet ist.“ Anmelden geht bis dahin nicht (gleiche Meldung).
|
||||
- Admins bekommen eine Benachrichtigung über die vorhandenen Kanäle (E-Mail/Push, neues Ereignis „Registrierung wartet“) und sehen in der Benutzerverwaltung oben eine Liste **„Wartet auf Freigabe“** mit Freigeben / Ablehnen. Ablehnen löscht das Konto.
|
||||
- Bei Freigabe geht eine E-Mail im bekannten Layout an die Person („Dein Konto ist freigeschaltet“ mit Knopf zur App).
|
||||
- Ohne SMTP funktioniert alles, nur ohne E-Mails.
|
||||
|
||||
**E-Mail-Endungen**
|
||||
- Liste von Domains, Vergleich ohne Groß-/Kleinschreibung auf den Teil nach `@` (keine Subdomains, außer ausdrücklich `*.firma.de`).
|
||||
- Optional: **E-Mail-Adresse bestätigen** per Link (wie Passwort vergessen, gleiche Token-Technik), bevor das Konto aktiv wird. Sonst kann jeder `chef@firma.de` eintippen. Bei `domain` sollte die Bestätigung Pflicht sein, wenn SMTP eingerichtet ist.
|
||||
|
||||
**Missbrauch**
|
||||
- Gleiche Bremse wie beim Login (Anfragen pro IP), dazu höchstens N wartende Registrierungen gleichzeitig (z. B. 50), danach „gerade keine Registrierung möglich“.
|
||||
- In der Demo bleibt Registrierung aus.
|
||||
|
||||
## 2. Einstellungen: Admin-Oberfläche oder Umgebungsvariablen
|
||||
|
||||
Damit die Admin-Einstellungen nicht überladen werden:
|
||||
|
||||
- **Admin-Oberfläche:** nur der Modus (Auswahlfeld) und darunter, je nach Modus, das Feld für die erlaubten Endungen. Die Liste „Wartet auf Freigabe“ steht in der Benutzerverwaltung, nicht in den Einstellungen.
|
||||
- **Umgebungsvariablen** (gewinnen, wenn gesetzt; das Feld in der Oberfläche ist dann gesperrt mit Hinweis „über die Umgebung festgelegt“ – so wie heute bei SMTP):
|
||||
|
||||
```env
|
||||
REGISTRATION_MODE=domain+approval # closed | approval | domain | domain+approval | open
|
||||
REGISTRATION_DOMAINS=firma.de,familie-dach.de
|
||||
REGISTRATION_VERIFY_EMAIL=true # Bestätigungslink vor Aktivierung
|
||||
```
|
||||
- `ALLOW_REGISTRATION=true` bleibt als Alias für `REGISTRATION_MODE=open` erhalten.
|
||||
|
||||
## 3. Einladungen bequemer
|
||||
|
||||
Heute: Der Admin erzeugt einen Code (optional an eine E-Mail-Adresse und Organisation gebunden, Rolle, Gültigkeit 1–365 Tage, **einmal** verwendbar) und gibt ihn weiter; die Person wählt auf der Anmeldeseite „Einladungscode“ und tippt ihn ab.
|
||||
|
||||
**Einladungslink**
|
||||
- Jeder Code ist auch als Link nutzbar: `https://<instanz>/?invite=<CODE>`. Der Link öffnet direkt die Registrierung mit eingetragenem Code; ist er an eine Adresse gebunden, steht die E-Mail schon fest.
|
||||
- Die App entfernt den Code nach dem Öffnen aus der Adresszeile (wie beim Passwort-Link).
|
||||
- In der Admin-Liste neben jedem Code: **Link kopieren** und – mit SMTP – **per E-Mail einladen** (Adresse eingeben → E-Mail im MindSift-Layout mit Knopf „Konto anlegen“, wer eingeladen hat, Gültigkeit). Beim Einladen per E-Mail wird der Code automatisch an diese Adresse gebunden.
|
||||
- Den Link baut die Admin-Oberfläche aus der aktuellen Adresse; nur die E-Mail braucht `BASE_URL`.
|
||||
|
||||
**Mehrere Personen mit einem Code**
|
||||
- Neues Feld **„Wie oft verwendbar“** (Standard 1, z. B. 20) – ein Link für eine ganze Gruppe, Klasse oder Familie. In der Liste steht „3 von 20 verwendet“, Widerrufen beendet ihn sofort.
|
||||
- Mehrfach-Codes können nicht an eine einzelne E-Mail gebunden werden, wohl aber an eine Organisation und an die erlaubten E-Mail-Endungen (Abschnitt 1).
|
||||
- Technisch: Spalten `max_uses` und `use_count` an `user_invites`; das bestehende „Code reservieren → Konto anlegen → als benutzt markieren“ zählt dann hoch statt abzuschließen (weiter ohne Doppelverwendung bei gleichzeitigen Registrierungen).
|
||||
|
||||
## 4. Anmelden mit anderen Konten (zurückgestellt)
|
||||
|
||||
### OpenID Connect (statt „nur Google“)
|
||||
|
||||
Ein allgemeiner OIDC-Login deckt Google, Microsoft, Apple und vor allem **selbst gehostete Anbieter** ab: Authentik, Keycloak, Authelia – und **Gitea/Forgejo selbst** kann OIDC-Anbieter sein. Für ein Homelab ist das meist nützlicher als Google.
|
||||
|
||||
- Konfiguration nur per Umgebung (Geheimnisse gehören nicht in die Datenbank-Oberfläche):
|
||||
```env
|
||||
OIDC_ISSUER=https://git.dsit.me/ # oder https://accounts.google.com
|
||||
OIDC_CLIENT_ID=…
|
||||
OIDC_CLIENT_SECRET=…
|
||||
OIDC_BUTTON_LABEL=Mit Gitea anmelden
|
||||
OIDC_ALLOWED_DOMAINS=firma.de # optional, sonst gilt der Registrierungsmodus
|
||||
OIDC_AUTO_CREATE=true # neues Konto beim ersten Login anlegen (Modus/Freigabe gelten)
|
||||
```
|
||||
- Ablauf: Authorization Code Flow mit PKCE, `state` und `nonce`; Rückkehr auf `BASE_URL/api/v1/auth/oidc/callback`. Konten werden über `iss`+`sub` verknüpft (neue Tabelle), nicht nur über die E-Mail.
|
||||
- Besteht schon ein Konto mit derselben, vom Anbieter **bestätigten** E-Mail, wird es nach Passwortabfrage verknüpft (kein stilles Übernehmen fremder Konten).
|
||||
- Danach normale MindSift-Sitzung (wie heute), Abmelden bleibt lokal.
|
||||
- Passwort-Login bleibt daneben möglich; optional `PASSWORD_LOGIN=false`, wenn nur noch OIDC gewünscht ist (Admin-Notzugang beachten).
|
||||
|
||||
Eine Anmeldung über den Mailserver (SMTP-Prüfung wie in Gitea) ist bewusst nicht vorgesehen: Das Mailpasswort liefe durch MindSift, und große Anbieter erlauben es meist nicht mehr.
|
||||
|
||||
## Reihenfolge und Aufwand (grob)
|
||||
|
||||
1. Einladungslink (Link kopieren, per E-Mail einladen) und mehrfach verwendbare Codes – klein bis mittel, sofort nützlich. (Mehrere Einzel-Codes auf einmal erzeugen ist damit überflüssig und entfällt.)
|
||||
2. Registrierungsmodus mit Freigabe und Endungen, Umgebungsvariablen, Admin-Liste „Wartet auf Freigabe“, E-Mails – mittel.
|
||||
3. E-Mail-Bestätigung per Link – klein (Technik wie Passwort vergessen).
|
||||
4. OIDC-Login – mittel (Bibliothek `coreos/go-oidc` + `golang.org/x/oauth2`, neue Tabelle für verknüpfte Konten, Knopf auf der Anmeldeseite).
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- Sollen Admins bei jeder wartenden Registrierung benachrichtigt werden oder reicht ein Zähler in der Benutzerverwaltung?
|
||||
- Neue Konten per Endung automatisch einer Organisation zuordnen (z. B. `@firma.de` → Organisation „Firma“)?
|
||||
- Welcher OIDC-Anbieter zuerst: Gitea, Google, Authentik …?
|
||||
- Soll ein abgelehnter Antrag gesperrt bleiben (gleiche Adresse kann es nicht erneut versuchen) oder einfach gelöscht werden?
|
||||
@@ -36,8 +36,9 @@ Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt
|
||||
|
||||
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||
|
||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken und keine „angehakten“ Buttons (Einrichtung, Admin-Bereich, Berechtigungen eines API-Keys, Bereiche beim Import und Wiederherstellen → `SectionPicker`).
|
||||
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
|
||||
- Eine Auswahl **einer** Option aus mehreren (Import-Modus, Ecken, Theme-Folgen) bleibt eine Button-Gruppe mit `role="radio"`.
|
||||
|
||||
## Kleine Dialoge (Anlegen, Umbenennen)
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
||||
<img src="screenshots/Triage_desktop.webp" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
||||
</p>
|
||||
|
||||
<sub>Links das helle, rechts das dunkle Theme.</sub>
|
||||
@@ -43,9 +43,9 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* Papierkorb mit Wiederherstellen.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||
|
||||
<img src="screenshots/Mobile_Workspace.png" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
||||
<img src="screenshots/Mobile_Workspace.webp" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
||||
</p>
|
||||
|
||||
### ✅ Aufgaben & Kanban
|
||||
@@ -61,7 +61,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
<img src="screenshots/aufgaben_desktop.webp" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
</p>
|
||||
|
||||
### 📝 Workspace: Outliner-Notizen
|
||||
@@ -88,11 +88,11 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
|
||||
<img src="screenshots/workspace_desktop.webp" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/journal_desktop.png" alt="MindSift Journal mit Kalender" width="90%" />
|
||||
<img src="screenshots/journal_desktop.webp" alt="MindSift Journal mit Kalender" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🛒 Geteilte Listen
|
||||
@@ -101,12 +101,12 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
||||
<img src="screenshots/Listen_desktop.webp" alt="MindSift Geteilte Listen" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🏢 Benutzer, Gruppen, Administration
|
||||
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*. Einladungen als Code oder Link (auch für mehrere Personen, auf Wunsch per E-Mail verschickt). Registrierung ohne Einladung wahlweise geschlossen, mit Freigabe durch einen Admin, nur für bestimmte E-Mail-Endungen oder offen, auf Wunsch mit Bestätigung der E-Mail-Adresse per Link – auch fest per Umgebungsvariable.
|
||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
|
||||
|
||||
@@ -117,18 +117,19 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/Theme_fjord.png" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_terracotta.png" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_fjord.webp" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_terracotta.webp" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/Theme_polarized.png" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_suri.png" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_polarized.webp" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
||||
<td align="center"><img src="screenshots/Theme_suri.webp" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🔌 Integrationen
|
||||
|
||||
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
|
||||
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen offene Aufgaben, haken sie ab, legen neue an und führen Einkaufs- und Checklisten. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
|
||||
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
||||
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
||||
|
||||
@@ -191,7 +192,10 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `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) |
|
||||
| `REGISTRATION_MODE` | leer (Admin-UI entscheidet) | Registrierung ohne Einladung: `closed`, `approval` (Admin gibt frei), `domain` (nur erlaubte E-Mail-Endungen), `domain+approval` (Endungen sofort, andere nach Freigabe), `open`. Gesetzt ist es im Admin-UI gesperrt |
|
||||
| `REGISTRATION_DOMAINS` | leer | Erlaubte E-Mail-Endungen für die `domain`-Modi, z. B. `firma.de, *.firma.de`; gesetzt im Admin-UI gesperrt |
|
||||
| `REGISTRATION_VERIFY_EMAIL` | leer (Admin-UI entscheidet) | `true`: Wer sich ohne Einladung registriert, bestätigt zuerst seine E-Mail-Adresse per Link (braucht SMTP und `BASE_URL`); unbestätigte Konten werden nach 48 Stunden gelöscht |
|
||||
| `ALLOW_REGISTRATION` | `false` | `true` = wie `REGISTRATION_MODE=open` (gesperrt); `false` lässt die Wahl dem Admin-UI |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# 📋 MindSift – Projekt-TODOs
|
||||
|
||||
> **Zweck & Kontext:**
|
||||
> Diese Datei enthält strukturierte, technische Arbeitsaufträge und Feature-Spezifikationen für ausführende Entwickler- und Agenten-KIs.
|
||||
> Sie wird fortlaufend gepflegt, priorisiert und mit konkreten Implementierungsdetails angereichert.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Frontend – Mobile Version
|
||||
*Aufgaben, UI/UX und Touch-Optimierungen für Smartphones & Tablets.*
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Frontend – Desktop Version
|
||||
*UI/UX, Tastatur-Shortcuts, Layouts und Ansichten für Desktop-Browser.*
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Backend & Schnittstellen
|
||||
*Go-Services, SQLite-Persistenz, REST/WebSocket-Endpunkte und Hintergrundjobs.*
|
||||
|
||||
### Allgemein / Core
|
||||
### Mobile-spezifisch
|
||||
- [ ] *(Aktuell keine offenen Aufgaben)*
|
||||
|
||||
### Desktop-spezifisch
|
||||
- [ ] *(Aktuell keine offenen Aufgaben)*
|
||||
|
||||
---
|
||||
|
||||
## 💡 Konzept, UX & Planung
|
||||
*Architektur, UI-Guidelines und systemübergreifende Entscheidungen.*
|
||||
|
||||
- [ ] **Anmeldung per OpenID Connect** (zurückgestellt, aktuell kein Bedarf) – Login über Gitea, Authentik, Google & Co. Ausgearbeitet in [Doku/plans/registrierung-und-login.md](Doku/plans/registrierung-und-login.md), Abschnitt 4.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Organisation & Dokumentation
|
||||
*Tickets, Dokumentation, Release-Vorbereitungen und DevOps/Deployment.*
|
||||
|
||||
---
|
||||
|
||||
## 📦 Verschiedenes / Backlog
|
||||
*Unsortierte Notizen, vage Ideen und Sonstiges.*
|
||||
|
||||
---
|
||||
|
||||
## ✅ Erledigt
|
||||
*Abgeschlossene Aufgaben und Features mit Referenz.*
|
||||
|
||||
- [x] **Theme-Manager in den Benutzereinstellungen (Upload & CSS-Editor im WordPress-Stil)** *(Commit `bf0d68e`)*
|
||||
- Theme-Upload (`.css`-Dateien), Copy-Paste von CSS, Vorlagen-Download/Kopieren, dynamische Live-Injection via `<style id="mindsift-custom-theme">` und Einbindung in die Theme-Galerie.
|
||||
- [x] **Persistenz für benutzerdefinierte Themes in SQLite & API-Endpunkte** *(Commit `bf0d68e`)*
|
||||
- SQLite-Tabelle `user_themes` und REST-Endpunkte (`GET / POST / DELETE /api/v1/themes`) für persistente Custom-Themes pro Benutzer.
|
||||
- [x] **Admin-Einstellungen: Checkboxen für SMTP durch Toggle-Switches ersetzen** *(Commit `ce321ce`)*
|
||||
- SMTP-Haken sind Toggle-Switches (`ToggleSwitch`), neuer Schalter „Zertifikatsprüfung deaktivieren“.
|
||||
- [x] **SMTP-Client & E-Mail-Alarme härten (Fix: `451 4.3.2 Internal server error`)** *(Commit `ce321ce`)*
|
||||
- Expliziter SMTP-Ablauf mit Fehlerangabe pro Schritt (`smtp_step`), EHLO mit Absender-Domain, nackte Adresse im Umschlag, `Date`/`Message-ID`, kodierter Betreff; Tests gegen Fake-SMTP-Server.
|
||||
- [x] **Option zur Deaktivierung der TLS-Zertifikatsprüfung für SMTP (`smtpSkipVerify`)** *(Commit `ce321ce`)*
|
||||
- `SMTP_SKIP_VERIFY` in Config, Systemeinstellungen und Admin-UI; gilt für Port 465 und STARTTLS.
|
||||
- [x] **Zentrale `CountBadge`-Komponente & einheitliches Theming schaffen** *(Commit `a62b204`)*
|
||||
- `CountBadge` (accent/muted, inline/corner, 99+) für Navigation, Tabs, Admin und mobiles Menü; `badge-count` entfernt; Vorlage leitet Akzent-Töne aus der Akzentfarbe ab.
|
||||
- [x] **Aufgaben-Counter im mobilen Listen-Menü für „Alle Aufgaben" ergänzen** *(Commit `a62b204`)*
|
||||
- „Alle Aufgaben“ zeigt im mobilen Menü die Zahl offener Aufgaben (inkl. geteilter Listen).
|
||||
- [x] **Aufgaben-Counter im Tab „Alle Aufgaben" anzeigen** *(Commit `a62b204`)*
|
||||
- Zähler am Tab; Backend zählt offene Aufgaben jetzt wie die Ansicht „Alle Aufgaben“ (auch fremde Aufgaben in geteilten Listen).
|
||||
- [x] **Badge-Positionierung in Tabs an Titelleiste/App-Icons angleichen (Top-Right)** *(Commit `a62b204`)*
|
||||
- Zähler sitzen oben rechts an den Tabs der Aufgabenlisten und Listen.
|
||||
- [x] **Impressum- & Datenschutz-Links im Login- und Einstellungs-Footer (mit Auto-Hide)** *(Commit `d6c70d7`)*
|
||||
- Links unter der Anmelde-/Einrichtungsbox und im Footer der Kontoeinstellungen (`LegalLinks`): URL im neuen Tab, Markdown im Lesefenster, unsichtbar ohne Konfiguration.
|
||||
- [x] **Konfigurierbare Rechtstexte (Impressum & Datenschutz via ENV)** *(Commit `d6c70d7`)*
|
||||
- `LEGAL_IMPRINT_/PRIVACY_URL` und `…_CONTENT` (auch `…_CONTENT_FILE`), nur `http(s)`-URLs; `legal` in `/auth/status`, öffentlich `GET /api/v1/legal/{imprint|privacy}`.
|
||||
- [x] **„Über MindSift"-Bereich mit Versionsinfo & GitHub-Link** *(Commit `d6c70d7`)*
|
||||
- Fenster „Über MindSift“ (Version, Lizenz, Philosophie, Repository-Link) aus Kontoeinstellungen und Hilfe; Link/Lizenz als Konstanten in `AboutModal.tsx` (Repo zeigt auf git.dsit.me, Lizenz noch nicht festgelegt).
|
||||
- [x] **Markdown-Tree-Export als direktes Streaming-ZIP (Obsidian-kompatibel)** *(Commit `e509f68`)*
|
||||
- `GET /api/v1/export/markdown` streamt die ZIP ohne Temp-Dateien: Ordnerstruktur wie im Workspace (inkl. geteilter), YAML-Frontmatter, Obsidian-taugliches Markdown, `Aufgaben/` im Tasks-Plugin-Format, `Listen/`.
|
||||
- [x] **Globale Schnellsuche & Spotlight-Palette (`Cmd+K` / `Ctrl+K`)** *(Commit `71fa53e`)*
|
||||
- Gemini; gemergt in `a044620`, geprüft (Zugriff, Cmd+K, Enter öffnet). Markierungsfarbe in `a62b189` ans Theme angepasst.
|
||||
- [x] **Volltextsuche auf SQLite FTS5 (Full-Text Search) umstellen** *(Commit `71fa53e`)*
|
||||
- Gemini; FTS5 mit Triggern, Backfill, BM25, Snippets, Zugriffsprüfung je Typ; gemergt in `a044620`.
|
||||
- [x] **Vollständige Emoji-Bibliothek für Notizen-Header einbinden** *(Commit `633bd6f`)*
|
||||
- Gemini; `emoji-picker-react` 4.12.0, lazy geladen; gemergt in `a044620`.
|
||||
- [x] **Emoji-Modal für Notiz-Icon responsiv & tastatur-optimiert umbauen** *(Commit `633bd6f`)*
|
||||
- Gemini; mobiles Bottom-Sheet mit Autofokus und großen Touch-Zielen; gemergt in `a044620`.
|
||||
- [x] **Demo-Modus: Automatischer Reset der Datenbank bei jedem Serverstart** *(Commit `9c6598c`)*
|
||||
- Gemini; Reset nur bei aktivem Demo-Modus (getestet), nutzt die gemeinsame Drop-Funktion; gemergt in `a044620`.
|
||||
- [x] **SMTP: `AUTH LOGIN`-Fallback & alternative SASL-Authentifizierung implementieren** *(Commit `d8a16c6`)*
|
||||
- `AUTH LOGIN` direkt, wenn nur LOGIN angeboten wird; nach abgelehntem PLAIN neue Verbindung mit LOGIN (net/smtp schließt nach gescheitertem AUTH). Scheitern beide mit 451, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers. Tests mit Fake-Server.
|
||||
- [x] **Markdown-Tree-Import aus ZIP (Obsidian-Vault-Support)** *(Commit `1601a6a`)*
|
||||
- Zwei Wege: `POST /import/mindsift` (JSON-Backup oder MindSift-Archiv, Rundreise inkl. Aufgaben/Listen/Triage) und `POST /import/obsidian` (Vault in eigenen Ordner, Checkboxen bleiben, Tags → #Hashtags). Vorschau mit `?preview=1`, Grenzen gegen ZIP-Bomben und `..`-Pfade, Tests.
|
||||
- [x] **UI für Obsidian- / Markdown-Tree Backup-Export und ZIP-Import integrieren** *(Commit `1601a6a`)*
|
||||
- Export-Button (`e509f68`) und zwei Import-Karten „MindSift-Daten“ / „Obsidian-Vault“ mit Drag & Drop, Vorschau, Bestätigen und Ergebnis (inkl. übersprungener Dateien).
|
||||
- [x] **Mobile Unterstützung für Backup-Download & ZIP-Import** *(Commit `1601a6a`)*
|
||||
- Download hängt den Link ins Dokument und gibt die URL verzögert frei (mobil getestet); Datei-Buttons mit 44 px Touch-Höhe, `accept=".json,.zip"`; Import mobil im Browser geprüft.
|
||||
- [x] **PWA Splash-Screen & Ladebildschirm: Vollflächig Blau mit zentriertem Logo** *(Commit `9bc8716`)*
|
||||
- Manifest mit kurzem Namen und `#2563eb`; blauer HTML-Platzhalter mit Logo in `#root` bis React startet (ohne JS geprüft); `theme-color` startet blau und folgt danach dem Theme. iOS-Startbilder (`apple-touch-startup-image`) gibt es weiterhin nicht.
|
||||
- [x] **Eingabefeld für API-Key-Namen auf mobilen Geräten vergrößern** *(Commit `0e9fd1b`)*
|
||||
- Feld und Button mobil 44 px, Schrift 16 px; Ursache war `flex-1` im untereinander gestapelten Layout, das die Höhe auf den Inhalt schrumpfte.
|
||||
- [x] **Admin-Settings: SMTP-Port-Eingabe reparieren & Typen-Mismatch beheben („Ungültiges JSON“)** *(Commit `a3a7094`)*
|
||||
- Port als Text (nur Ziffern) im Formular, lässt sich leeren; Server akzeptiert Zahl oder Text (`SystemSettings.UnmarshalJSON`), getestet.
|
||||
- [x] **MindSift-Backup-Import: Modus-Auswahl „Hinzufügen“ vs. „Wiederherstellen (Alles überschreiben)“** *(Commit `0e9fd1b`)*
|
||||
- „Hinzufügen“ / „Wiederherstellen“ mit Warnung und Pflicht-Haken; `?mode=restore` löscht die eigenen Daten erst nach vollständiger Prüfung der Datei, in einer Transaktion.
|
||||
- [x] **Gefahrenzone in Benutzereinstellungen: „Alle Daten löschen“ & „Konto löschen“** *(Commit `0e9fd1b`)*
|
||||
- „Daten löschen“ und „Konto löschen“ im Bereich Profil & Sicherheit, beide mit Passwort; Konto-Button für den letzten Admin deaktiviert mit Hinweis; nach Kontolöschung Abmeldung zur Login-Seite.
|
||||
- [x] **Selbstbedienung: Daten löschen & Konto löschen (inkl. Schutz für letzten Admin)** *(Commit `0e9fd1b`)*
|
||||
- `DELETE /user/data`, `DELETE /user/account` (Passwort nötig, 403 für letzten Admin); `PurgeUserData` in einer Transaktion, Aufgaben anderer in eigenen geteilten Listen bleiben deren Aufgaben; auch Admin-Löschung räumt jetzt vollständig auf.
|
||||
- [x] **UI für wiederkehrende Aufgaben (Auswahl & Icon)** *(Commit `588c679`)*
|
||||
- Auswahl im Detail-Fenster (Keine / 🔁 Täglich / Wöchentlich / Monatlich / Jährlich) mit Hinweis; dezentes Wiederholungs-Symbol an Zeilen und Kanban-Karten.
|
||||
- [x] **Wiederkehrende Aufgaben: Automatische Folge-Aufgabe bei Erledigung erzeugen** *(Commit `588c679`)*
|
||||
- Spalte `recurrence`; Abhaken auf allen Wegen (Detail, Liste, Kanban, Notiz-Checkbox) erzeugt die Nachfolgerin mit kopierten Feldern, zurückgesetzten Unteraufgaben und fortgeschriebenem Datum (Monatsende abgefangen), live per `TASK_CREATED`; höchstens eine Nachfolgerin pro Aufgabe; Export/Import behalten die Regel.
|
||||
- [x] **Mobile Eingabe für Fälligkeits-Uhrzeit optimieren** *(Commit `344f5e4`)*
|
||||
- Datum- und Uhrzeitfeld nebeneinander, mobil 44 px hoch mit nativen Pickern; „✕ Uhrzeit“ in der Schnellwahl macht die Aufgabe wieder ganztägig, ✕ entfernt die Fälligkeit.
|
||||
- [x] **Fälligkeiten mit Uhrzeit im Task-Detail-Modal & Aufgabenansichten unterstützen** *(Commit `344f5e4`)*
|
||||
- Format `YYYY-MM-DDTHH:MM` (API nimmt auch Leerzeichen/Sekunden an); Anzeige „Heute, 14:30“, „Morgen“, „Fr., 06.11.“; Überfälliges minutengenau rot; Gruppieren/Sortieren nach lokalem Tag und Uhrzeit (vorher UTC-Datum). Schnellwahl behält die Uhrzeit, Wiederholungen auch, Markdown-Export/-Import ebenso.
|
||||
- [x] **Alarm-System härten: `alarm_sent` zurücksetzen & Fälligkeiten mit Uhrzeit auswerten** *(Commit `344f5e4`)*
|
||||
- Alarm zur Uhrzeit (ganztägig 9:00) minus Vorlaufzeit, Zeitzone `TZ` (Standard Europe/Berlin, tzdata eingebettet); neue Fälligkeit oder Wiederöffnen (Detail, Kanban, Notiz-Checkbox, Triage) setzt `alarm_sent` zurück; Folgeaufgaben starten mit 0. Mails zeigen „30.10.2026, 14:30 Uhr“. Offen in FLAW-12: Backoff bei Fehlern.
|
||||
- [x] **Sicherheitslücke FLAW-04: Fest eincodiertes Fallback-`JWT_SECRET` absichern** *(Commit `4cdbcbd`)*
|
||||
- Ohne `JWT_SECRET` oder mit bekanntem Platzhalter erzeugt der Server einen Zufallswert und speichert ihn als `jwt.secret` (0600) im Datenverzeichnis.
|
||||
- [x] **Sicherheitslücke FLAW-05: SSRF-Schutz für Webhook- und Discord-Alarm-URLs** *(Commit `62463bc`)*
|
||||
- https-Pflicht, Discord nur discord.com/api/webhooks/, eigener Resolver beim Verbinden (localhost/Link-Local/Metadaten immer gesperrt), Heimnetz und http nur mit Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“, keine Proxys, max. 3 Weiterleitungen.
|
||||
- [x] **Papierkorb für Notizen: Soft-Delete, Wiederherstellung & endgültiges Löschen** *(Commit `f69f74f`)*
|
||||
- `is_archived` + `archived_at`/`archived_by`; Löschen (auch ganzer Ordner) verschiebt in den Papierkorb; raus aus Baum, Suche, Export, Statistik und für alle Empfänger unsichtbar; Endpunkte `GET/DELETE /documents/trash`, `POST /documents/{id}/restore`, `DELETE /documents/{id}/permanent`; Wiederherstellen legt fehlende Ordner neu an.
|
||||
- [x] **Papierkorb-Ansicht & Wiederherstellung im Workspace (Notizen)** *(Commit `f69f74f`)*
|
||||
- Eintrag mit Zähler unten in der Ordnerleiste; Liste mit Ordner und Löschzeitpunkt, „Wiederherstellen“, „Endgültig löschen“ und „Papierkorb leeren“ jeweils mit zweitem Tipp; Notiz im Papierkorb schreibgeschützt mit Hinweisleiste und „Wiederherstellen“.
|
||||
- [x] **Papierkorb für Notizen im mobilen Workspace integrieren** *(Commit `f69f74f`)*
|
||||
- Menüpunkt „Papierkorb“ mit Zähler im mobilen Menü und in der Ordnerliste; mobile Ansicht mit 44-px-Buttons und Zurück-Pfeil.
|
||||
- [x] **Backlink-Index & Abfrage für Notizen („What links here“)** *(Commit `677fe0f`)*
|
||||
- `GET /documents/{id}/backlinks`: Notizen mit `[[Titel]]` (auch `|Alias`, `#Abschnitt`, Groß/klein egal), nur sichtbare, ohne Papierkorb und die Notiz selbst; bis zu 5 Fundstellen pro Notiz plus `more`.
|
||||
- [x] **Wiki-Style Backlink-Bereich („What links here“) am Notiz-Ende** *(Commit `677fe0f`)*
|
||||
- Einklappbarer Bereich „Verlinkt auf diese Notiz (n)“ am Notiz-Ende mit Icon, Titel, Ordner und hervorgehobenem Link im Bullet; Klick springt direkt zum Bullet; bei 0 ausgeblendet.
|
||||
- [x] **Passwort-Reset per E-Mail: Token-Generierung, Mailversand & Session-Invalidierung** *(Commit `5e20710`)*
|
||||
- Tabelle `password_reset_tokens` (nur SHA-256), Link 1 h und einmal gültig, höchstens alle 2 Min. pro Konto; neutrale Antwort, Versand im Hintergrund; Link nur aus `BASE_URL`; neues Passwort entwertet alle Links und beendet alle Sitzungen. Nur aktiv mit SMTP + gesetzter `BASE_URL`, nicht im Demo-Modus.
|
||||
- [x] **Passwort-Zurücksetzen-Flow im Login-Screen („Passwort vergessen?“)** *(Commit `5e20710`)*
|
||||
- „Passwort vergessen?“ am Passwortfeld (nur wenn verfügbar), Formular mit neutraler Bestätigung; `?resetToken=` öffnet „Neues Passwort festlegen“ (mit Wiederholung), Token verschwindet sofort aus der Adresszeile; danach Hinweis zur Anmeldung, bei abgelaufenem Link „Neuen Link anfordern“.
|
||||
- [x] **PWA Offline-Fähigkeit & App-Shell-Caching (Service Worker & Sync-Queue)** *(Commit `7bba174`)*
|
||||
- Service Worker: Seiten Network-First mit gecachter index.html, Bundles Cache-First, Icons/Manifest SWR, Cache-Versionen; GET-Antworten pro Benutzer in IndexedDB (App startet offline angemeldet); Outbox für erfasste Gedanken und abgehakte Aufgaben/Listeneinträge, gesendet bei `online`, Start und alle 30 s; Banner „Offline · n Änderungen warten“. Im Browser mit Flugmodus geprüft.
|
||||
- [x] **Refactoring FLAW-20: Monolithische Go-Dateien (`server.go` & `sqlite.go`) domänenspezifisch aufteilen** *(Commit `74c394e`)*
|
||||
- Reine Verschiebung per AST-Werkzeug: `storage` → users, apikeys, thoughts (inkl. Alarme), tasklists, documents, folders, tags, lists, teams, system; `api` → handlers_auth/account/thoughts/documents/folders/lists/tasklists/teams/admin/integrations, realtime.go, alarms.go. sqlite.go 637, server.go 460 Zeilen; alle Tests grün.
|
||||
- [x] **Passwortregeln im Frontend härten (Mindestens 8 Zeichen & Validierungshinweise)** *(Commit `b5f926b`)*
|
||||
- Gemeinsame Prüfung `lib/password.ts` in Setup, Registrierung/Einladung, Reset, Profil und Admin (Anlage + Reset) mit eigener Meldung „Das Passwort muss mindestens 8 Zeichen lang sein“ statt Browser-Popup; Platzhalter angepasst.
|
||||
- [x] **Sicherheitslücke FLAW-09: Passwort-Mindestlänge auf 8 Zeichen anheben & zentral validieren** *(Commit `b5f926b`)*
|
||||
- `auth.ValidatePassword` (≥ 8 Zeichen, nicht nur Leerzeichen, ≤ 72 Bytes) in allen sechs Pfaden; bestehende kürzere Passwörter funktionieren beim Login weiter. Nebenbei: Profil/Admin-Reset kürzen das Passwort nicht mehr (Leerzeichen am Rand machten den Login unmöglich). Offen in FLAW-09: Rate-Limit.
|
||||
- [x] **Alarm-Logik härten (FLAW-12): Fehlversuche begrenzen (Max. 3 Versuche bei Ausfall aller Kanäle)** *(Commit `d198281`)*
|
||||
- Spalte `alarm_failures`; nach 3 Durchläufen ohne zustellenden Kanal wird der Alarm aufgegeben (Warnung im Log); Erfolg, neue Fälligkeit oder Wiederöffnen setzen zurück. FLAW-12 damit vollständig behoben.
|
||||
- [x] **Such-Button in der Desktop-Titelleiste als runder Icon-Button (Lupe im Kreis)** *(Commit `6d720c0`)*
|
||||
- Runder Icon-Button nur mit Lupe, Tastenkürzel im Tooltip.
|
||||
- [x] **Header-Navigation: Workspace-Bubble entfernen & Listen-Bubble ergänzen** *(Commit `c4fc455`)*
|
||||
- Neues Feld `listItemsOpenCount` (offene Einträge aller eigenen, direkt und per Gruppe geteilten Listen, mit Test); Akzent-Zähler an Listen in Header und mobiler Leiste, Workspace ohne Zähler; Stats aktualisieren sich bei Listen-Events und nach Löschen.
|
||||
- [x] **Workspace-Tabs: Einheitliche `CountBadge`-Bubbles für To-Dos und Hashtags (Notizen ohne Bubble)** *(Commit `4bf060b`)*
|
||||
- To-Dos und Hashtags mit `CountBadge` auf dem Label, Notizen ohne Zähler.
|
||||
- [x] **Workspace: Lokale Notizen-Suche entfernen bzw. auf globale Spotlight-Suche umstellen** *(Commit `e7851f1`)*
|
||||
- Lokaler Filter entfernt; an seiner Stelle ein Button „Notizen durchsuchen… ⌘K“ (mobil 44 px), der die globale Volltextsuche öffnet.
|
||||
- [x] **Tab-Badges optimieren: Positionierung um ca. 4px nach unten und 4px nach rechts korrigieren** *(Commit `6d6a35d`)*
|
||||
- Alle Tab-Zähler `-top-0.5 -right-2` mit `pr-3` am Label (Header, Aufgabenlisten, Listen, Workspace); geprüft in Dark, Light und mobil; ui-patterns.md angepasst.
|
||||
- [x] **Such-Icon in der Titelleiste: Vollständig kreisrundes Styling (`rounded-full`) sicherstellen** *(Commit `afd3b03`)*
|
||||
- Regel `.bg-panel.border.rounded-full` / `.bg-canvas.border.rounded-full` in index.css: ein explizites `rounded-full` gewinnt jetzt in allen Themes gegen den Theme-Radius (betrifft auch andere runde Buttons/Avatare).
|
||||
- [x] **Demo-Modus: Automatischer 20-Minuten-Reset-Ticker & Reset-Event** *(Commit `6b9eb62`)*
|
||||
- `DEMO_RESET_MINUTES` (Standard 20, 0 = aus); feste IDs der Demo-Konten, damit Sitzungen den Reset überleben; laufende Anfragen sperren den Reset (Streams ausgenommen); `DEMO_RESET` an alle Verbindungen; `/auth/status` liefert `demoNextReset` und `demoResetInSeconds`.
|
||||
- [x] **Demo-Modus: Live-Countdown-Timer & Reset-Banner in der Titelleiste** *(Commit `6b9eb62`)*
|
||||
- „✨ Demo · Reset in MM:SS“ statt der Versionsnummer (Desktop und mobil, Tooltip); nach dem Reset Cache geleert, Ansichten neu geladen, Hinweis „Demo-Instanz wurde zurückgesetzt“; ohne Event fragt die App nach Ablauf selbst nach. Live mit 1-Minuten-Reset geprüft.
|
||||
- [x] **Rate-Limiting & temporäre Kontosperre bei Login-Fehlversuchen (FLAW-09 Abschluss)** *(Commit `11c14fa`)*
|
||||
- Sperre nach Fehlversuchen: Konto + Adresse 3, Konto 20, Adresse 30, je 15 Min., 429 mit Retry-After; so kann ein Angreifer den Admin nicht von außen aussperren. Erfolg bzw. Passwort-Reset heben die Sperre auf. Client-IP nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`, Standard private Netze, `all` = altes Verhalten); `LOGIN_PROTECTION=false` schaltet alles ab.
|
||||
- [x] **API-Key Scopes einschränken & Webhook-Ingest für Triage, Aufgaben und Listeneinträge** *(Commit `5c711c2`)*
|
||||
- Scopes `webhook:triage|task|list` (Standard Triage, alte Keys → Triage); Ingest mit `target` task (optional `taskListId`) und `list_item` (`listId`, nur mit Schreibrecht), 403 ohne Scope; Keys öffnen keine Live-Streams mehr. Nebenbei FLAW-27 behoben (3900c61): Listeneinträge waren ohne Rechteprüfung änderbar.
|
||||
- [x] **API-Key Scopes im Ecosystem-Tab (Auswahl bei Erstellung & Badge-Anzeige)** *(Commit `5c711c2`)*
|
||||
- Die sichtbare Key-Verwaltung lag in den Kontoeinstellungen, `EcosystemView` war toter Code und ist entfernt; neues `ApiKeysPanel` mit Scope-Auswahl (mind. einer), Badges je Key, Simulator für Inbox/Aufgabe/Listeneintrag und Beispielen mit `X-API-Key`-Header.
|
||||
- [x] **Automatische Server- & Benutzer-Backups (Backlog / Später geplant)** *(Commit `c97765e, 64e077d`)*
|
||||
- Admin: Schalter, Uhrzeit, Aufbewahrung (Standard 03:00, 3 Tage), Liste, Download, „Jetzt sichern“; täglich DB-Abzug (`VACUUM INTO`, gzip) und ein ZIP je Benutzer unter `backups/`, verpasste Läufe werden nachgeholt. Benutzer: eigene Server-Backups herunterladen oder einspielen (ersetzen/hinzufügen, nach Bereichen Notizen/Aufgaben/Listen/Inbox/Themes); Import mit derselben Bereichsauswahl; Themes jetzt in Export/Import.
|
||||
- [x] **Quick-Add FAB: Hold-to-Record Sprachaufnahme mit Swipe-to-Cancel** *(Commit `eabb018, 3739872`)*
|
||||
- Von Gemini umgesetzt (HoldToRecordFab: Tippen öffnet wie bisher, Halten nimmt per Spracherkennung auf und speichert in die Inbox, nach oben ziehen bricht ab, Rückfall ohne Spracherkennung, Tests). Nachgebessert: Bedienung per Tastatur/Screenreader, Erfolgsmeldung erst nach dem Speichern, Randfarbe im Abbruch-Zustand.
|
||||
- [x] **Sicherheit S1: Sichere Links & Sicherheits-Header (FLAW-28, FLAW-29)** *(Commit `b2ecda9`)*
|
||||
- Links nur `http`/`https`/`mailto` (`safeHref`); CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`; Inline-Scripts der Startseite in Dateien verschoben.
|
||||
- [x] **Sicherheit S2: Serverseitige Sitzungen & aktuelles Passwort (FLAW-30, FLAW-31)** *(Commit `1af3a6e`)*
|
||||
- Tabelle `sessions` (ersetzt `revoked_tokens`), gleitend 30 Tage, Token-Erneuerung über `X-Session-Token`; Geräteliste mit Einzel-Abmelden im Profil; Passwort/E-Mail nur mit aktuellem Passwort, Hinweismail an alte Adresse. Upgrade meldet alle einmal ab.
|
||||
- [x] **Sicherheit S3: Längere IDs, Einladungscodes, Rate-Limits (FLAW-32, FLAW-39, FLAW-40)** *(Commit `3709282`)*
|
||||
- IDs 96 Bit, Codes `INV-XXXXX-…` mit 100 Bit; Registrierung 20 und „Passwort vergessen“ 10 Anfragen pro Adresse in 15 Min.; Einladung wird vor dem Konto atomar beansprucht.
|
||||
- [x] **Sicherheit S4: Demo, SMTP-Passwort, Limits, Backups, Downloads (FLAW-33 bis FLAW-38)** *(Commit `9d96981`)*
|
||||
- Demo verschickt nichts und schützt die gemeinsamen Konten (403 „In der Demo deaktiviert“, Hinweise in der UI); SMTP-Passwort nie an den Browser; `?token=` nur API-Keys; 2-MB-Körperlimit; Konto löschen entfernt Server-Backups; 30 Min. für große Downloads/Importe.
|
||||
- [x] **Triage: Listenauswahl überspringen, wenn es nur eine beschreibbare Liste gibt** *(Commit `3a27a37`, gemergt in `ef16778`)*
|
||||
- Von Gemini umgesetzt (Desktop-Button „Auf ‹Liste›“, mobiles Aktions-Sheet, Warteschlange, Schnellerfassung; nur lesbar geteilte Listen ausgeblendet; Tests). Nachgebessert: `useMemo` für die Listenfilterung in der Schnellerfassung. Release v0.6.0.
|
||||
- [x] **Serverseitige Whisper-Anbindung (ENV-Konfiguration, `/api/v1/voice/transcribe`, Compose-Dienst)** *(Commit `7801cd1`)*
|
||||
- Abweichend vom ursprünglichen Entwurf nur per ENV (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY`, `STT_DISABLED`, Schutzwerte `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB`, `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS`); URL gesetzt = Server-Modus, Demo immer Browser. Admin zeigt nur eine Statuskarte mit Testaufnahme. Compose-Profil `whisper` (speaches 0.8.3-cpu, Modelle in `./whisper`, UID 1000); MindSift stößt den Modell-Download selbst an. Echt getestet mit speaches (tiny/small/large-v3-turbo).
|
||||
- [x] **Spracheingabe im Frontend auf den Server-Modus umstellen (V2)** *(Commit `7fa21e4`)*
|
||||
- Gemeinsamer Hook `useVoiceInput` + `VoiceWave` für Quick-Dump-Leiste, mobiles Erfassen-Sheet und Halte-Knopf; Server-Modus mit MediaRecorder, echten Pegel-Wellen und „Wird erkannt…“, Browser-Modus wie bisher, Mikrofon ausgeblendet bei `off`/ohne Unterstützung. Mit speaches im Browser geprüft (Desktop, Handy halten, Handy-Sheet).
|
||||
- [x] **Workspace-Sidebar: Cleanes Datei-Explorer-Layout ohne Sektions-Überschriften** *(Commit `9f3c78d, gemergt a9d0e49`)*
|
||||
- Von Gemini 3.8 umgesetzt: keine Überschriften mehr, angeheftete Notizen oben (mit kleinem Pin, Trenner), darunter Ordner und Notizen der Hauptebene; angeheftete Root-Notizen nicht doppelt; Tests.
|
||||
- [x] **Workspace `/code`-Block: Sprachauswahl & Live-Syntax-Highlighting im VSCodium / VS-Code-Stil** *(Commit `ff53f06, gemergt 9124809, Fix cc68d3f`)*
|
||||
- Von Gemini 4 umgesetzt: CodeBlockEditor mit Sprachauswahl (~50 Sprachen, nachgeladen), Live-Highlighting als Overlay, Kopieren/Als Text, Tab/Shift+Tab, `/code yaml` und ```yaml; Farben als Theme-Variablen (Dark+/Light+) in allen Themes. Nachgebessert: Fokus blieb nach der Umwandlung nicht im Block (Tippen löste App-Kürzel aus).
|
||||
- [x] **Docker-Setup vereinfachen: Whisper-Service auskommentieren statt `COMPOSE_PROFILES=whisper`** *(Commit `e385a44`)*
|
||||
- Profil entfernt; whisper-Block in docker-compose.yml auskommentiert mit Schritt-für-Schritt-Anleitung darüber (Rauten entfernen, STT_URL setzen, Ordner für UID 1000 anlegen); eingeschaltet als gültiges YAML geprüft. README, Architektur-Doku und CHANGELOG angepasst.
|
||||
- [x] **Docker-Umgebung: Erklärungskommentare der Variablen von `docker-compose.yml` nach `.env.example` verschieben** *(Commit `e385a44`)*
|
||||
- .env.example komplett auf Deutsch kommentiert (Funktion, Standard, Einheiten, Auswahl), nach Themen gegliedert; docker-compose.yml nur noch Zuordnungen mit Verweis oben auf .env/.env.example. Nebenbei: TZ, TRUSTED_PROXIES, LOGIN_PROTECTION, DEMO_RESET_MINUTES, LEGAL_*_CONTENT wurden vorher gar nicht durchgereicht.
|
||||
- [x] **Medien in Notizen: Ablage im Backend (M1)** *(Commit `177fa49`)*
|
||||
- Tabelle `media`, Dateien in `data/media/<docId>/`; Upload mit Schreibrecht, Abruf mit Leserecht (auch kopierte Blöcke), Format am Inhalt erkannt (kein SVG), externe Bilder per SSRF-geschütztem Kopieren; Verweis im Blockinhalt (`media:<id>`), daher keine Änderung am Sync-Modell; Aufräumen nach 24 h ohne Verweis; Grenzen `MEDIA_*`.
|
||||
- [x] **`/bild` (M2)** *(Commit `e8fa14a`)*
|
||||
- Nur im leeren Punkt; Auswahl inline (Datei, Kamera, Drag & Drop, Strg+V, Link), Esc bricht ab; Browser verkleinert auf 2048 px und speichert WebP (EXIF/GPS entfällt); Vollbild und Beschriftung.
|
||||
- [x] **`/audio` (M3)** *(Commit `3f5e3b2`)*
|
||||
- Bereit-Bubble mit grünem Mikrofon, Aufnahme rot mit Live-Wellen links und Zeit, danach Inline-Player; Datei-Upload; WebM-Dauer-Fix; „Transkribieren“ mit eigener Spracherkennung.
|
||||
- [x] **`/diktat` (M4)** *(Commit `3f2bc96`)*
|
||||
- Startet sofort, vollflächiger Wellen-Button mit halbtransparentem „Stopp“, Text landet als normaler Text im Punkt; nur angeboten, wenn Spracheingabe verfügbar.
|
||||
- [x] **Medien in Backup, Export und Import (M6)** *(Commit `1f63e08`)*
|
||||
- Backup-ZIP mit `media/`, Markdown-Archiv mit `attachments/` (relative Einbettung), Rück-Import mit neuen IDs; Obsidian-Import übernimmt eingebettete Bilder/Audio, ungenutzte Dateien als übersprungen gemeldet.
|
||||
- [x] **Workspace Notizen-Editor: Mobiles Padding reduzieren & horizontales Ausweichen bei tiefen Einrückungen** *(Commit `83408d1`)*
|
||||
- Rand mobil pl-2/pr-1, Einrückung per CSS-Variable 14 px (ab sm 22 px), Zeilen-Mindestbreite 14rem mobil mit seitlichem Scrollen des Inhalts; zusätzlich Zoom-Knopf mobil ausgeblendet (Tipp auf den Punkt zoomt) und Löschen nur in der aktiven Zeile. Gemessen: schmalste Textzeile 128 → 244 px (Demo-Notiz), bei 16 Ebenen ≥ 174 px.
|
||||
- [x] **Slash-Befehle vereinheitlichen: `/image` statt `/bild`, knappe Texte & Layout-Fix im Menü** *(Commit `1632dd2`)*
|
||||
- /image statt /bild (Aliase bild, foto; auch sprachnotiz, dictate), kurze Texte, Menü w-72, Label nowrap, Beschreibung truncate rechtsbündig. /diktat bleibt deutsch wie im ursprünglichen Auftrag.
|
||||
- [x] **Admin-Einstellungen: E-Mail-Absendername (`SMTP_FROM_NAME`) konfigurierbar machen & Vorbelegung** *(Commit `56c1fa0`)*
|
||||
- Feld „Absender-Name (SMTP_FROM_NAME)“ mit Platzhalter MindSift und Hinweis; Platzhalter der Absenderadresse auf reine Adresse geändert.
|
||||
- [x] **E-Mail-Absendername unterstützen (`SMTP_FROM_NAME` & Default „MindSift“)** *(Commit `56c1fa0`)*
|
||||
- SMTP_FROM_NAME (Standard MindSift) in Config, system_settings (smtp_from_name), Compose und .env.example; reine Adresse bekommt den Namen im From-Header, ein Name in SMTP_FROM hat Vorrang, MAIL FROM bleibt die reine Adresse; Test mit Umlauten.
|
||||
- [x] **Link-Vorschau über Punkten mit Web-Adresse (M5)** *(Commit `4c26eb2`)*
|
||||
- Server holt https-Seiten SSRF-geschützt, liest og:/twitter:-Tags, Titel, Beschreibung, Icon, Bild; Cache 7 Tage in `link_previews`; kompakte Karte über dem Punkt, ✕ macht `[Titel](url)` daraus (synchronisiert), globaler Schalter unter Darstellung; in der Demo aus. Echt getestet mit Wikipedia und go.dev (Handy und Desktop).
|
||||
- [x] **Outliner: Bulletpoint (inkl. Unterbaum) in eigenständige Notiz umwandeln** *(Commit `4d1b37c`)*
|
||||
- Knopf am Punkt (Desktop) und /extract bzw. /notiz; serverseitig über das Operationsprotokoll (offene Editoren folgen live); Titel ohne Hashtags (bleiben am Link), eindeutig mit (2); gleicher Ordner; Freigaben übernommen, fremder Besitzer bekommt Schreibrecht; Hinweis mit „Öffnen“.
|
||||
- [x] **Notizen zusammenführen (Merge Documents, z. B. per Drag & Drop in der Sidebar)** *(Commit `4d1b37c`)*
|
||||
- Drag & Drop Notiz auf Notiz in der Sidebar (Hervorhebung) oder Knopf im Notizkopf mit Suche; Bestätigungsdialog; Quelle (muss eigen sein) als h2-Abschnitt mit Unterpunkten angehängt, Quelle in den Papierkorb; Toast mit vollständigem Rückgängig (Abschnitt weg, Quelle zurück). Medien bleiben erreichbar.
|
||||
- [x] **Link-Vorschau nur bei `/link` im leeren Bulletpoint & Slash-Menü Schließen-Verhalten reparieren** *(Commit `446f8e4`)*
|
||||
- Gemini 4: Karte nur bei Punkt = genau eine https-Adresse (bzw. /link in leerem Punkt); Slash-Menü nur am Anfang/nach Leerzeichen, Esc/Backspace schließen.
|
||||
- [x] **Workspace Outliner: Enter-Sperre am letzten leeren Bulletpoint entfernen** *(Commit `446f8e4`)*
|
||||
- Gemini 4: Sperre entfernt, Enter am Ende legt neuen Punkt an.
|
||||
- [x] **Slash-Befehls-Menü (`/`): Ausgewähltes Element bei Pfeiltastennavigation automatisch ins Sichtfeld scrollen** *(Commit `446f8e4`)*
|
||||
- Gemini 4: aktiver Eintrag scrollt per scrollIntoView mit.
|
||||
- [x] **`/diktat`: Live-Mitschreiben & kein vorzeitiger Abbruch bei Sprechpausen** *(Commit `6795edb`)*
|
||||
- Gemini 4: Option live in useVoiceInput; Browser-Neustart bei Pausen, Server-Modus in Stücken (eigener Recorder je Abschnitt), mitwachsender Text und Zeit.
|
||||
- [x] **`/extract` Race-Condition & Konflikt hinter Proxys/Polling beheben (Lokale Link-Ersetzung)** *(Commit `362dd3b`)*
|
||||
- Umbau: Server legt nur die Notiz an und liefert den Link, der Editor ersetzt den Ast selbst über die Sync-Engine (keine Konflikte, sofort sichtbar, auch bei Polling).
|
||||
- [x] **Workspace-Sidebar: Ausgelagerte Notiz (`/extract`) sofort ohne Tab-Wechsel in der Dateiliste anzeigen** *(Commit `362dd3b`)*
|
||||
- onDocCreated/onDocRemoved vom Editor an WorkspaceView, Ordner wird aufgeklappt.
|
||||
- [x] **Rückgängig-Funktion (Strg+Z / Undo) für ausgelagerte Notizen (`/extract`)** *(Commit `362dd3b`)*
|
||||
- Strg+Z der Engine holt den Ast zurück, POST /documents/{id}/extract/undo legt die neue Notiz in den Papierkorb; Toast mit Öffnen und Rückgängig.
|
||||
- [x] **Workspace Header & Aktionen aufräumen: Undo/Redo-Buttons, Drei-Punkte-Menü & Status-Pills nach oben in die Tab-Leiste** *(Commit `5f27e7c`)*
|
||||
- Gemini 4: Status-Pillen in der Tab-Leiste, Undo/Redo-Knöpfe, Drei-Punkte-Menü (Anheften, Zusammenführen, Export, Löschen).
|
||||
- [x] **Benutzer-Einstellungen: Granulare Benachrichtigungen & Kanal-Routing (E-Mail & Discord)** *(Commit `d2afcf4, a69bcba`)*
|
||||
- Gemini 4 Oberfläche (Matrix, Tageszusammenfassung, Beobachten-Knopf), Claude Backend (Routing, Bündelung 5 Min., Watcher, Pause, eigene Änderungen); Gruppen-Freigaben in der Tageszusammenfassung (6b3c32a). Release v0.8.0.
|
||||
- [x] **HTML-E-Mail-Templates mit Branding & Watcher-Benachrichtigungen für E-Mail & Discord** *(Commit `a69bcba`)*
|
||||
- multipart/alternative mit schlichtem HTML (inline Styles, keine externen Ressourcen), Discord als ein Embed; gebündelte Nachrichten und Tageszusammenfassung.
|
||||
- [x] **Code-Blöcke im Workspace (`CodeBlockEditor`): Zeilennummern links anzeigen** *(Commit `1d68638`)*
|
||||
- Zeilennummernspalte mit synchronem Scrollen, horizontaler Bildlauf statt Umbruch (v0.9.0).
|
||||
- [x] **Outliner: Standard-Formatierungs-Shortcuts (Strg+B, Strg+I, Strg+K, etc.) für Textauswahl unterstützen** *(Commit `a317ce0`)*
|
||||
- Strg/Cmd+B, +I (umschaltend), +K (Link bzw. Link-Dialog); nicht in Code-Blöcken und schreibgeschützten Notizen (v0.9.0).
|
||||
- [x] **Workspace: Doppelten Scrollbalken (Outer Window & Inner Editor) beheben** *(Commit `6c154af`)*
|
||||
- Workspace füllt das Fenster (100dvh, Flex), nur die Notiz scrollt; am Handy kein getrenntes Scrollen mehr (seitlicher Bildlauf-Bereich behält seine Höhe).
|
||||
- [x] **Workspace: Drag & Drop-Zustand beim Abbruch bereinigen (Fix für dauerhaft sichtbares „Auf Hauptebene verschieben“)** *(Commit `6c154af`)*
|
||||
- Globaler dragend-Listener räumt alle Zieh-Zustände auf.
|
||||
- [x] **Workspace: Sidebar-Toggle-Button bei eingeklappter Leiste dezent stylen (nur Icon in Akzentfarbe)** *(Commit `6c154af`)*
|
||||
- Rahmen neutral, nur das Symbol in Akzentfarbe.
|
||||
- [x] **Build & Versionierung: `-dev`-Suffix strikt auf `mindsift:dev` beschränken** *(Commit `6c154af`)*
|
||||
- main-Builds bekommen v<package-version> als APP_VERSION, Tags den Tag, alles andere -dev.
|
||||
- [x] **Eigener (ausgehender) Webhook ist nicht mehr einstellbar** *(Commit `261579f`)*
|
||||
- Dritter Kanal „Webhook“ in der Matrix und der Tageszusammenfassung, JSON-Digest (event: digest).
|
||||
- [x] **⏸ Zurückgestellt: Workspace: „Heute“-Button für tägliche Notizen (Daily Notes / Logseq- & Obsidian-Workflow)** *(Commit `9008d7c`)*
|
||||
- Als Journal umgesetzt: eine Notiz pro Tag, endlos untereinander, Kalender in der Seitenleiste, Teilen des sichtbaren Tags, pro Gerät abschaltbar.
|
||||
- [x] **Outliner: Nummerierte Listen (`/number`) & Markdown-Einfügen (Absätze, Nummern)** *(Commit `2bfe74e`)*
|
||||
- 1./a./i. nach Verschachtelung, Enter setzt fort, leerer Punkt beendet; Einfügen: Leerzeile trennt Absätze, Einzelabsatz ging verloren (behoben).
|
||||
- [x] **Outliner: Block-Typ-Rückumwandlung in Standardtext (`/text`, Menü & Undo-Sicherheit)** *(Commit `fd72500`)*
|
||||
- /text (p, paragraph, bullet, standard), Aufgaben-Menü und Aktionsblase wandeln zurück; Trennlinie per Backspace/Entf (v0.10.0).
|
||||
- [x] **Outliner: Slash-Command Menü bei Klick ins Leere / Blur schließen & Trigger-Kontext anpassen** *(Commit `fd72500`)*
|
||||
- Erkennung an der Cursorposition, kontextabhängige Befehle, schließt bei Klick daneben und Blur (v0.10.0).
|
||||
- [x] **Outliner: Hässlichen Zeilenumbruch durch TODO-3-Punkte-Menü & Badges beheben** *(Commit `fd72500`)*
|
||||
- Badges und Menü aus dem Fließtext gelöst (v0.10.0).
|
||||
- [x] **Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen) als schwebendes Overlay statt Flex-Breitenverengung** *(Commit `fd72500`)*
|
||||
- Schwebende Blase, Textbreite bleibt gleich (v0.10.0).
|
||||
- [x] **Outliner: Schriftgrößen für H1, H2 und H3 deutlich anheben (H1 auf Titel-Niveau)** *(Commit `fd72500`)*
|
||||
- H1 xl/2xl, H2 lg/xl, H3 base; Notiztitel dünn und größer (v0.10.0).
|
||||
- [x] **Design: Zähler-Badges (`CountBadge`) & Slash-Command-Auswahl mit reduzierter Deckkraft (Opacity 0.6) stylen** *(Commit `1753208`)*
|
||||
- Akzent-Badge über accent-dim mit Rand, Muted über panel-elevated; Slash-Auswahl dezent (v0.10.0).
|
||||
- [x] **Quick Dump / Instant-Capture: Unnötiges Scrolling deaktivieren, Platzhaltertext kürzen & Absende-Pfeil nach unten umdrehen** *(Commit `1753208`)*
|
||||
- Kurzer Platzhalter, kein Scrollen einzeilig, Pfeil nach unten (v0.10.0).
|
||||
- [x] **Triage-Deck: Mobile Schnellaktions-Buttons (Entfernen, Aufgabe, Liste, Notiz) unter der Karte einblenden** *(Commit `1753208`)*
|
||||
- Leiste Wegwerfen/Liste/Notiz/Aufgabe unter der Karte (v0.10.0).
|
||||
- [x] **Workspace: „Neuer Ordner“-Modal zentriert unter dem Mauszeiger öffnen (Oberkante mittig)** *(Commit `1753208`)*
|
||||
- FormSheet mittig unter dem Klick mit Rand-Schutz (v0.10.0).
|
||||
- [x] **Light Theme: Hintergrundfarben in Notizen korrigieren (Sidebar grau, Editor weiß)** *(Commit `2542e0e`)*
|
||||
- Editor weiß, Ordnerleiste grau über --bg-editor/--bg-sidebar (v0.10.0).
|
||||
- [x] **PWA Web Share Target: Text, Links und Bilder aus Smartphone-Apps direkt in den MindSift-Posteingang teilen** *(Commit `86d2cd0`)*
|
||||
- Teilen-Ziel im Manifest, Service Worker legt geteilte Inhalte ab, Import in den Posteingang; Bilder als Hinweistext (Gemini).
|
||||
- [x] **Outliner: Deep-Linking & Verlinkung einzelner Bulletpoints (Block-Referenzen)** *(Commit `1a49e6b`)*
|
||||
- [[Notiz#^id|Notiz › Auszug]], kopieren an jedem Punkt, Auswahl im Link-Dialog, Sprung mit Hervorhebung, Hinweis wenn der Punkt fehlt.
|
||||
- [x] **Workspace: Sidebar-Kopfzeile aufräumen (Überschrift entfernen, Suche & Aktions-Buttons in eine Zeile)** *(Commit `d408e7d`)*
|
||||
- Überschrift entfernt, Suche + neuer Ordner + neue Notiz in einer Zeile.
|
||||
- [x] **Workspace: Ablenkungsfreier Fokus-Modus (Zen-Modus) mit Auto-Hide-Header** *(Commit `226c863`)*
|
||||
- Strg+Umschalt+F / Menü, Lesebreite, Kopfleiste am oberen Rand, Esc beendet.
|
||||
- [x] **Workspace: Notiz-Aktionsleiste in die obere Tab-Leiste integrieren, Editor-Headerleiste komplett entfernen & Statusanzeigen vereinheitlichen** *(Commit `d408e7d`)*
|
||||
- Desktop: Aktionen per Portal in der Workspace-Leiste, feste Reihenfolge, Speicherstatus nur als Symbol; Handy: Titel erst nach Wegscrollen.
|
||||
- [x] **Workspace Journal: Umschaltbares Schließen (Toggle), prominentes Auffahr-Panel & Zustandskonsistenz** *(Commit `375dda9`)*
|
||||
- Panel mit Rahmen, zweiter Klick schließt, Zustand bleibt über Tab-Wechsel.
|
||||
- [x] **Outliner: Hierarchisches Einrücken nach Überschriften & Erkennung von Markdown-Listen (`*`, `-`, `1.`)** *(Commit `375dda9`)*
|
||||
- Einfügen und Import verschachteln Abschnitte unter Überschriften, Enter an Überschrift legt Unterpunkt an, - / * / + als Kürzel.
|
||||
- [x] **Globaler App-Footer mit Versionsnummer, „Über MindSift“, Rechtstexten & Custom-Link (inkl. Demo-Modus)** *(Commit `c181ffe`)*
|
||||
- Footer am Desktop, Über-Dialog, LEGAL_CUSTOM_LINK_* (Gemini).
|
||||
- [x] **Web Push-Benachrichtigungen bei Fälligkeiten über Service Worker & VAPID (Browser & Handy)** *(Commit `38b8c92`)*
|
||||
- Push als Kanal in der Matrix und Tageszusammenfassung, VAPID automatisch, Gerät pro Browser ein-/ausschalten, Test; Verschlüsselung per Test geprüft.
|
||||
- [x] **CI/CD & Pipeline: Dreifachen Build & redundante Testläufe bei Release-Merge/Tags optimieren** *(Commit `141f87f`)*
|
||||
- Tests einmal pro Code-Stand, Release-Tag taggt latest um; Zuordnung als Image-Label statt Zusatz-Tags.
|
||||
@@ -40,4 +40,4 @@ Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem
|
||||
|
||||
## Screenshots
|
||||
|
||||
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
||||
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Danach werden sie verkleinert (Desktop 1280 px, Handy 480 px breit) und als WebP mit Qualität 90 gespeichert (`cwebp` in einem Alpine-Container). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
||||
@@ -196,7 +196,10 @@ const main = async () => {
|
||||
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
|
||||
await page.mouse.click(1000, 860);
|
||||
await sleep(2500);
|
||||
// Round corners (setting "Ecken: rund") for this picture only
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-corners', 'round'));
|
||||
await plain(page, 'journal_desktop.png', 'dark');
|
||||
await page.evaluate(() => document.documentElement.removeAttribute('data-corners'));
|
||||
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
|
||||
|
||||
await tab(page, 'Listen');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
||||
# in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
|
||||
# The PNGs from the browser are scaled down (desktop 1280 px, phone 480 px wide) and saved as WebP.
|
||||
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
|
||||
set -euo pipefail
|
||||
ENGINE="${ENGINE:-podman}"
|
||||
@@ -21,6 +22,7 @@ cleanup() {
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$WORK/png"
|
||||
|
||||
echo "Baue Image …"
|
||||
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
||||
@@ -32,5 +34,14 @@ for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/d
|
||||
echo "Erstelle Screenshots …"
|
||||
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
|
||||
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
|
||||
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
-v "$ROOT/e2e":/e2e:Z -v "$WORK/png":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
||||
|
||||
echo "Wandle in WebP um …"
|
||||
"$ENGINE" run --rm -v "$WORK/png":/png:Z -v "$ROOT/screenshots":/shots:Z docker.io/library/alpine:3.20 sh -c '
|
||||
apk add -q libwebp-tools >/dev/null
|
||||
for f in /png/*.png; do
|
||||
name=$(basename "$f" .png)
|
||||
case "$name" in Mobile_*) width=480 ;; *) width=1280 ;; esac
|
||||
cwebp -quiet -resize "$width" 0 -q 90 -m 6 -metadata none "$f" -o "/shots/$name.webp"
|
||||
done'
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.22.0",
|
||||
"version": "0.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.22.0",
|
||||
"version": "0.28.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.22.0",
|
||||
"version": "0.28.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
AVAILABLE_THEMES,
|
||||
customThemeOption,
|
||||
initThemeOnBoot,
|
||||
hasChosenTheme,
|
||||
DEMO_THEME,
|
||||
watchSystemScheme,
|
||||
isCustomThemeId,
|
||||
setActiveTheme as applyTheme,
|
||||
@@ -56,6 +58,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { OfflineBanner } from './components/OfflineBanner';
|
||||
import { useServerOnline } from './lib/useServerOnline';
|
||||
import { SharedImportToast } from './components/SharedImportToast';
|
||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||
import { DemoCountdown } from './components/DemoCountdown';
|
||||
@@ -179,6 +182,7 @@ export const App: React.FC = () => {
|
||||
|
||||
// Profile Dropdown State
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const serverOnline = useServerOnline();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -593,6 +597,13 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||
|
||||
// Demo: first visit on this device starts with a round theme instead of the square default
|
||||
useEffect(() => {
|
||||
if (!authStatus.demoMode || hasChosenTheme()) return;
|
||||
applyTheme(DEMO_THEME);
|
||||
setActiveThemeState(DEMO_THEME);
|
||||
}, [authStatus.demoMode]);
|
||||
|
||||
// Web Share Target: import shared thoughts from external apps
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
@@ -947,7 +958,17 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Profil & Einstellungen"
|
||||
>
|
||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||
<span className="relative">
|
||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||
{/* Connection status like in a chat app: green online, red without server */}
|
||||
<span
|
||||
role="status"
|
||||
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
|
||||
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
|
||||
serverOnline ? 'bg-status-ok' : 'bg-status-err'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1013,8 +1034,20 @@ export const App: React.FC = () => {
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||
{user.displayName}
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||
{user.displayName}
|
||||
</span>
|
||||
{!serverOnline && (
|
||||
<span
|
||||
role="status"
|
||||
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
|
||||
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
|
||||
offline
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||
</button>
|
||||
|
||||
@@ -16,7 +16,8 @@ interface FormSheetProps {
|
||||
}
|
||||
|
||||
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
||||
// fields, square at the bottom with only a top border (like the other sheets: it docks to the keyboard
|
||||
// even with a rounded theme, whose panel-surface would round all corners). Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
||||
export const FormSheet: React.FC<FormSheetProps> = ({
|
||||
title,
|
||||
icon,
|
||||
@@ -56,7 +57,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
||||
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl rounded-b-none border-x-0 border-b-0 border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||
anchor
|
||||
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Shield,
|
||||
@@ -43,13 +43,71 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const [createSampleData, setCreateSampleData] = useState(false);
|
||||
|
||||
// --- Login / Invite / Register State ---
|
||||
// An invite link (?invite=CODE) opens the invite form with the code filled in
|
||||
const [linkInviteCode] = useState(() => new URLSearchParams(window.location.search).get('invite')?.trim().toUpperCase() || '');
|
||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||
resetToken ? 'reset' : 'login'
|
||||
resetToken ? 'reset' : linkInviteCode ? 'invite' : 'login'
|
||||
);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState(linkInviteCode);
|
||||
// Link from the confirmation mail (?verifyToken=…): confirm right away and say what happens next
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get('verifyToken');
|
||||
if (!token) return;
|
||||
url.searchParams.delete('verifyToken');
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
|
||||
setAuthTab('login');
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((res) => setNotice(res.message))
|
||||
.catch((err) => setError(err.message || 'Der Bestätigungslink ist ungültig oder abgelaufen.'));
|
||||
}, []);
|
||||
// Login refused because the address is not confirmed: offer to send the link again
|
||||
const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
const resendVerification = async () => {
|
||||
setResendState('sending');
|
||||
try {
|
||||
await api.resendVerification(email.trim());
|
||||
setResendState('sent');
|
||||
setError(null);
|
||||
setNotice('Falls die Adresse noch auf Bestätigung wartet, haben wir dir den Link erneut geschickt.');
|
||||
} catch (err: any) {
|
||||
setResendState('idle');
|
||||
setError(err.message || 'Senden fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
// The code is in the form now; it should not stay in the address bar (history, screenshots)
|
||||
useEffect(() => {
|
||||
if (!linkInviteCode) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('invite');
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
|
||||
}, [linkInviteCode]);
|
||||
// Hint on the sign-up form: which addresses get in, and whether an admin approves first
|
||||
const registrationHint = (() => {
|
||||
const domains = (authStatus.registrationDomains || []).map((d) => '@' + d.replace(/^\*\./, '')).join(', ');
|
||||
switch (authStatus.registrationMode) {
|
||||
case 'approval':
|
||||
return 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
|
||||
case 'open':
|
||||
return authStatus.emailVerification ? 'Du bekommst eine E-Mail, um deine Adresse zu bestätigen.' : '';
|
||||
case 'domain':
|
||||
return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : '';
|
||||
case 'domain+approval':
|
||||
return domains
|
||||
? `Mit Adressen von ${domains} geht es sofort, andere Konten gibt ein Administrator frei.`
|
||||
: 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
const registrationHintFull =
|
||||
registrationHint && authStatus.emailVerification && authStatus.registrationMode !== 'open'
|
||||
? `${registrationHint} Vorher bestätigst du deine Adresse per E-Mail.`
|
||||
: registrationHint;
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -167,6 +225,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
try {
|
||||
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
||||
const res = await api.register(email.trim(), password, displayName.trim(), code);
|
||||
if (res.pending || !res.user) {
|
||||
// Waiting for approval: back to the login form with the explanation
|
||||
setNotice(res.message || 'Dein Konto wartet auf die Freigabe durch einen Administrator.');
|
||||
setPassword('');
|
||||
setAuthTab('login');
|
||||
return;
|
||||
}
|
||||
onAuthenticated(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen');
|
||||
@@ -481,7 +546,17 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
{error && (
|
||||
<div className="mb-4 alert-err flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<span className="flex-1">{error}</span>
|
||||
{/bestätige zuerst deine E-Mail/i.test(error) && email.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void resendVerification()}
|
||||
disabled={resendState !== 'idle'}
|
||||
className="btn-action h-8 px-2.5 text-[11px] shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{resendState === 'sending' ? 'Sende…' : 'Link erneut senden'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && (
|
||||
@@ -687,6 +762,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
{(authTab === 'invite' || authTab === 'register') && (
|
||||
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
||||
{authTab === 'register' && registrationHintFull && (
|
||||
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHintFull}</p>
|
||||
)}
|
||||
{authTab === 'invite' && (
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Einladungscode *</label>
|
||||
|
||||
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
{
|
||||
id: 'triage-webhooks',
|
||||
category: 'triage',
|
||||
title: 'Externe Erfassung per Webhooks & API',
|
||||
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen',
|
||||
title: 'Webhooks, API & KI-Agenten (MCP)',
|
||||
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen; Aufgaben und Listen für Claude Code, Codex & Co.',
|
||||
icon: Bell,
|
||||
keywords: [
|
||||
'webhooks',
|
||||
@@ -504,11 +504,19 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
'shortcuts',
|
||||
'automation',
|
||||
'curl',
|
||||
'mcp',
|
||||
'ki',
|
||||
'agent',
|
||||
'claude',
|
||||
'codex',
|
||||
],
|
||||
content: (
|
||||
<div className="space-y-2 text-muted leading-relaxed">
|
||||
<p>
|
||||
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.
|
||||
Unter <em className="text-main">Kontoeinstellungen → Webhooks, KI-Agenten & Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, abhaken und anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist.
|
||||
</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.
|
||||
@@ -656,7 +664,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<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.
|
||||
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Webhooks, KI-Agenten & 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.
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
Sparkles,
|
||||
X,
|
||||
ShoppingCart,
|
||||
CloudOff,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
@@ -119,6 +121,15 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
|
||||
const [isPhone, setIsPhone] = useState(() => typeof window !== 'undefined' && window.innerWidth < 640);
|
||||
const [showAddSheet, setShowAddSheet] = useState(false);
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsPhone(window.innerWidth < 640);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
||||
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
|
||||
@@ -138,9 +149,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
// Entries added offline and not sent yet stay visible (also after a reload without connection)
|
||||
const pending = pendingListItems();
|
||||
const deduplicated = all.map((l) => ({
|
||||
...l,
|
||||
items: deduplicateItems(l.items || []),
|
||||
items: deduplicateItems([
|
||||
...pending.filter((p) => p.listId === l.id).map((p) => ({ ...p, isCompleted: false, sortOrder: 0 }) as ListItem),
|
||||
...(l.items || []),
|
||||
]),
|
||||
}));
|
||||
setLists(deduplicated);
|
||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||
@@ -268,6 +284,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
};
|
||||
}, [activeList?.id]);
|
||||
|
||||
// Back online: once the outbox has sent the entries added offline, show the server's version
|
||||
const listsRef = useRef(lists);
|
||||
listsRef.current = lists;
|
||||
const loadListsRef = useRef(loadLists);
|
||||
loadListsRef.current = loadLists;
|
||||
useEffect(() => {
|
||||
const onOutbox = () => {
|
||||
const shown = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX)));
|
||||
const queued = new Set(pendingListItems().map((p) => p.id));
|
||||
const sent = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX) && !queued.has(i.id)));
|
||||
if (shown && sent) void loadListsRef.current();
|
||||
};
|
||||
window.addEventListener('mindsift:outbox-changed', onOutbox);
|
||||
return () => window.removeEventListener('mindsift:outbox-changed', onOutbox);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
@@ -289,6 +321,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
|
||||
const handleAddItem = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Keep the field (and the phone keyboard) for the next item: refocus inside the tap
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
if (!activeList || !newItemText.trim() || isSubmittingItem) return;
|
||||
setIsSubmittingItem(true);
|
||||
triggerHaptic(15);
|
||||
@@ -804,6 +838,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
}, [openItems]);
|
||||
|
||||
const focusNewItemInput = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
setShowAddSheet(true);
|
||||
return;
|
||||
}
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
@@ -886,6 +924,99 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
]);
|
||||
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
||||
|
||||
// The add form's fields: inline on the desktop, on phones in a sheet that "+" opens (no fixed field
|
||||
// taking room above the list); the sheet brings its own buttons
|
||||
const renderAddItemFields = (withButton: boolean) =>
|
||||
activeList ? (
|
||||
<>
|
||||
{/* The item is the main field: a real input like the quantity next to it, with the + inside */}
|
||||
<div className="relative flex-1 w-full min-w-0">
|
||||
<Plus className="w-4 h-4 text-accent absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus={!withButton}
|
||||
value={newItemText}
|
||||
onChange={(e) => {
|
||||
setNewItemText(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
setShowSuggestions(true);
|
||||
setSuggestionIndex(0);
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onSelect={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
placeholder={`Was soll auf „${activeList.title}“? (z. B. Hafermilch #Kühlregal)`}
|
||||
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
||||
className="w-full h-11 sm:h-9 pl-9 pr-3 input-field text-base sm:text-sm"
|
||||
/>
|
||||
|
||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
|
||||
<div className="absolute left-6 top-full mt-2 w-64 max-h-56 overflow-y-auto bg-panel-elevated border border-border rounded-btn shadow-xl py-1 z-50 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
|
||||
Hashtags
|
||||
</div>
|
||||
{matchingSuggestions.map((tag, idx) => {
|
||||
const isSelected = idx === suggestionIndex;
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(tag);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 sm:py-1.5 text-xs flex items-center justify-between transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent/20 text-accent font-medium'
|
||||
: 'text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-accent font-mono font-semibold">#</span>
|
||||
<span>{tag}</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] text-faint font-mono">↵</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={newItemQty}
|
||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
placeholder="Menge"
|
||||
aria-label="Menge (optional)"
|
||||
className="w-24 sm:w-20 h-11 sm:h-9 px-2.5 input-field text-base sm:text-sm"
|
||||
/>
|
||||
{withButton && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingItem || !newItemText.trim()}
|
||||
className="btn-primary flex-1 sm:flex-none h-11 sm:h-9 px-4 text-sm sm:text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-1 flex-shrink-0"
|
||||
>
|
||||
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// Connection pill in the same look as the notes' header
|
||||
const livePill = (
|
||||
<span
|
||||
@@ -1084,92 +1215,29 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
{/* Active Shared List View */}
|
||||
{activeList ? (
|
||||
<div className="space-y-4">
|
||||
{/* Quick-Add Form */}
|
||||
<form
|
||||
onSubmit={handleAddItem}
|
||||
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-2 sm:p-1.5 relative"
|
||||
>
|
||||
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
|
||||
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={newItemText}
|
||||
onChange={(e) => {
|
||||
setNewItemText(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
setShowSuggestions(true);
|
||||
setSuggestionIndex(0);
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onSelect={(e) => {
|
||||
setCursorPosition(e.currentTarget.selectionStart);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
||||
// 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"
|
||||
/>
|
||||
{/* Phones: "+" opens the add form as a sheet; it stays open for the next item */}
|
||||
{isPhone && showAddSheet && (
|
||||
<FormSheet
|
||||
title={`Auf „${activeList.title}“`}
|
||||
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowAddSheet(false)}
|
||||
onSubmit={handleAddItem}
|
||||
submitLabel="Hinzufügen"
|
||||
busy={isSubmittingItem}
|
||||
>
|
||||
<div className="flex flex-col gap-2 relative">{renderAddItemFields(false)}</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
|
||||
<div className="absolute left-6 top-full mt-2 w-64 max-h-56 overflow-y-auto bg-panel-elevated border border-border rounded-btn shadow-xl py-1 z-50 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
|
||||
Hashtags
|
||||
</div>
|
||||
{matchingSuggestions.map((tag, idx) => {
|
||||
const isSelected = idx === suggestionIndex;
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(tag);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 sm:py-1.5 text-xs flex items-center justify-between transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent/20 text-accent font-medium'
|
||||
: 'text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-accent font-mono font-semibold">#</span>
|
||||
<span>{tag}</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="text-[10px] text-faint font-mono">↵</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmittingItem || !newItemText.trim()}
|
||||
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Quick-Add Form (desktop; on phones it is the sheet above) */}
|
||||
{!isPhone && (
|
||||
<form
|
||||
onSubmit={handleAddItem}
|
||||
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
|
||||
>
|
||||
{renderAddItemFields(true)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Open Items Grouped by Hashtags / Categories */}
|
||||
{openItems.length === 0 ? (
|
||||
@@ -1268,6 +1336,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<span className="text-sm font-medium text-main truncate">
|
||||
{displayContent}
|
||||
</span>
|
||||
{item.id.startsWith(OFFLINE_ITEM_PREFIX) && (
|
||||
<span title="Offline hinzugefügt – wird gesendet, sobald die Verbindung zurück ist">
|
||||
<CloudOff className="w-3.5 h-3.5 text-faint shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editingQtyItemId === item.id ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -15,9 +15,13 @@ import {
|
||||
UserPlus,
|
||||
Building2,
|
||||
Pencil,
|
||||
Link2,
|
||||
Clock,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
RegistrationMode,
|
||||
SharingMode,
|
||||
SystemSettings,
|
||||
User,
|
||||
@@ -78,6 +82,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteOrg, setInviteOrg] = useState('');
|
||||
// A link for several people (1 = classic single-use code)
|
||||
const [inviteMaxUses, setInviteMaxUses] = useState(1);
|
||||
// "Per E-Mail einladen": which invite the address field is open for
|
||||
const [mailInviteId, setMailInviteId] = useState<string | null>(null);
|
||||
const [mailInviteTo, setMailInviteTo] = useState('');
|
||||
const [mailInviteBusy, setMailInviteBusy] = useState(false);
|
||||
|
||||
// --- Organizations State ---
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@@ -128,6 +138,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'organizations',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
|
||||
registrationDomains: sys.registrationDomains || '',
|
||||
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
|
||||
registrationVerifyEmail: Boolean(sys.registrationVerifyEmail),
|
||||
registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv),
|
||||
emailLinksAvailable: Boolean(sys.emailLinksAvailable),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||
@@ -197,15 +213,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
organizationId: inviteOrg || undefined,
|
||||
maxUses: inviteMaxUses,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||
text:
|
||||
inv.maxUses > 1
|
||||
? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).`
|
||||
: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||||
@@ -221,12 +241,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteCode = (inv: UserInvite) => {
|
||||
navigator.clipboard.writeText(inv.code);
|
||||
// Copies the code or the link that opens the sign-up form with the code filled in
|
||||
const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`;
|
||||
const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code');
|
||||
const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => {
|
||||
navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code);
|
||||
setCopiedWhat(what);
|
||||
setCopiedInviteId(inv.id);
|
||||
setTimeout(() => setCopiedInviteId(null), 2000);
|
||||
};
|
||||
|
||||
const handleSendInvite = async (inv: UserInvite) => {
|
||||
if (!mailInviteTo.trim()) return;
|
||||
setMailInviteBusy(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim());
|
||||
setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i)));
|
||||
setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` });
|
||||
setMailInviteId(null);
|
||||
setMailInviteTo('');
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' });
|
||||
} finally {
|
||||
setMailInviteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Registrations waiting for approval (registration mode "Mit Freigabe")
|
||||
const handleApproveUser = async (u: User) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminApproveUser(u.id);
|
||||
setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item)));
|
||||
setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
@@ -273,7 +326,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const handleDeleteUser = async (u: User) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||
u.pendingApproval
|
||||
? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.`
|
||||
: `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -642,26 +697,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
|
||||
<span>Einladung erzeugen (gültig für 7 Tage)</span>
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{/* E-mail gets the room, "how often" only needs a few digits */}
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-3 ${
|
||||
isServerAdmin ? 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)_minmax(0,1fr)]' : 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)]'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
value={inviteMaxUses > 1 ? '' : inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Leer lassen für beliebige E-Mail"
|
||||
disabled={inviteMaxUses > 1}
|
||||
placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'}
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1 whitespace-nowrap">Wie oft verwendbar</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={inviteMaxUses}
|
||||
onChange={(e) => setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))}
|
||||
title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe"
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
|
||||
<label className="block text-muted mb-1">Rolle</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||
@@ -697,7 +771,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Einladungscode generieren</span>
|
||||
<span>{inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : 'Einladungscode generieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -709,63 +783,168 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
Aktive & bisherige Einladungscodes:
|
||||
</p>
|
||||
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
||||
{invites.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="p-2 flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||
{inv.code}
|
||||
</code>
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
</span>
|
||||
{inv.usedBy ? (
|
||||
<span className="badge-neutral text-[10px]">
|
||||
Eingelöst
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-ok text-[10px]">
|
||||
Offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{invites.map((inv) => {
|
||||
const maxUses = inv.maxUses || 1;
|
||||
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
|
||||
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
|
||||
const copied = copiedInviteId === inv.id;
|
||||
return (
|
||||
<div key={inv.id} className="p-2 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2.5 min-w-0">
|
||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||
{inv.code}
|
||||
</code>
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
|
||||
</span>
|
||||
{open ? (
|
||||
<span className="badge-ok text-[10px]">Offen</span>
|
||||
) : (
|
||||
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!inv.usedBy && (
|
||||
<button
|
||||
onClick={() => handleCopyInviteCode(inv)}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{open && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyInvite(inv, 'link')}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
title={inviteLink(inv)}
|
||||
>
|
||||
<Link2 className="w-3 h-3" />
|
||||
<span>{copied && copiedWhat === 'link' ? 'Kopiert!' : 'Link kopieren'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyInvite(inv, 'code')}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMailInviteId(mailInviteId === inv.id ? null : inv.id);
|
||||
setMailInviteTo(inv.email || '');
|
||||
}}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
title="Einladung per E-Mail verschicken"
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>Per E-Mail</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Einladung löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{mailInviteId === inv.id && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSendInvite(inv);
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
|
||||
</button>
|
||||
<input
|
||||
type="email"
|
||||
value={mailInviteTo}
|
||||
onChange={(e) => setMailInviteTo(e.target.value)}
|
||||
readOnly={Boolean(inv.email)}
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
autoFocus
|
||||
className="flex-1 min-w-[12rem] h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mailInviteBusy}
|
||||
className="btn-primary h-8 px-3 text-xs font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>{mailInviteBusy ? 'Sende…' : 'Einladung senden'}</span>
|
||||
</button>
|
||||
{maxUses === 1 && !inv.email && (
|
||||
<span className="w-full text-[11px] text-faint">Die Einladung gilt danach nur noch für diese Adresse.</span>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Einladung löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Registrations waiting for approval */}
|
||||
{users.some((u) => u.pendingApproval) && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||
Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})
|
||||
</h3>
|
||||
<div className="border border-status-warn/40 divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||
{users
|
||||
.filter((u) => u.pendingApproval)
|
||||
.map((u) => (
|
||||
<div key={u.id} className="p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-main truncate flex items-center gap-2">
|
||||
{u.displayName}
|
||||
{u.emailUnverified && <span className="badge-neutral text-[10px] font-normal">E-Mail unbestätigt</span>}
|
||||
</p>
|
||||
<p className="text-muted text-[11px] truncate">
|
||||
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApproveUser(u)}
|
||||
className="btn-primary h-11 sm:h-8 px-3 text-xs font-semibold flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Freigeben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteUser(u)}
|
||||
className="btn-action h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 hover:text-status-err"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-main">
|
||||
Registrierte Benutzer ({users.length})
|
||||
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||
{users.map((u) => (
|
||||
{users.filter((u) => !u.pendingApproval).map((u) => (
|
||||
<div key={u.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -774,8 +953,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
<span className={`${u.emailUnverified ? 'badge-neutral' : 'badge-err'} text-[10px]`}>
|
||||
{u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1136,27 +1315,82 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</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>
|
||||
{/* Who may register without an invite */}
|
||||
{(() => {
|
||||
const mode = settings.registrationMode || 'closed';
|
||||
const locked = Boolean(settings.registrationConfiguredViaEnv);
|
||||
const needsDomains = mode === 'domain' || mode === 'domain+approval';
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div>
|
||||
<p className="font-bold text-main">Registrierung ohne Einladung</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
|
||||
immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={mode}
|
||||
disabled={locked}
|
||||
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
|
||||
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
|
||||
>
|
||||
<option value="closed">Geschlossen – nur mit Einladung</option>
|
||||
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
|
||||
<option value="domain">Nur bestimmte E-Mail-Endungen – sofort aktiv</option>
|
||||
<option value="domain+approval">Endungen sofort, alle anderen nach Freigabe</option>
|
||||
<option value="open">Offen – jeder, sofort aktiv</option>
|
||||
</select>
|
||||
{needsDomains && (
|
||||
<label className="block text-[11px] text-muted">
|
||||
Erlaubte E-Mail-Endungen (durch Komma getrennt, <code className="font-mono">*.firma.de</code> auch für Subdomains)
|
||||
<input
|
||||
type="text"
|
||||
disabled={locked}
|
||||
value={settings.registrationDomains || ''}
|
||||
onChange={(e) => setSettings((prev) => ({ ...prev, registrationDomains: e.target.value }))}
|
||||
onBlur={() => handleSaveSystemSettings()}
|
||||
placeholder="firma.de, familie-dach.de"
|
||||
className="block w-full mt-1 h-11 sm:h-8 px-2.5 input-field font-mono text-base sm:text-xs disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{locked && (
|
||||
<p className="text-[11px] text-status-warn">
|
||||
Über die Umgebung festgelegt (<code className="font-mono">REGISTRATION_MODE</code> /{' '}
|
||||
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
|
||||
</p>
|
||||
)}
|
||||
{mode !== 'closed' && (
|
||||
<div className="pt-3 border-t border-border flex items-start justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<p className="font-semibold text-main">E-Mail-Adresse bestätigen lassen</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe
|
||||
landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht.
|
||||
</p>
|
||||
{!settings.emailLinksAvailable && (
|
||||
<p className="text-[11px] text-status-warn mt-1">
|
||||
Wirkt erst mit eingerichtetem Mail-Server und gesetzter <code className="font-mono">BASE_URL</code>.
|
||||
</p>
|
||||
)}
|
||||
{settings.registrationVerifyConfiguredViaEnv && (
|
||||
<p className="text-[11px] text-status-warn mt-1">
|
||||
Über <code className="font-mono">REGISTRATION_VERIFY_EMAIL</code> festgelegt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={Boolean(settings.registrationVerifyEmail)}
|
||||
disabled={Boolean(settings.registrationVerifyConfiguredViaEnv)}
|
||||
onChange={(registrationVerifyEmail) => handleSaveSystemSettings({ registrationVerifyEmail })}
|
||||
title="E-Mail-Adresse bei der Registrierung bestätigen lassen"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -8,31 +8,41 @@ import {
|
||||
Webhook,
|
||||
Zap,
|
||||
Terminal,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
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' },
|
||||
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
|
||||
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
|
||||
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
|
||||
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
|
||||
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
|
||||
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
|
||||
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
|
||||
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
|
||||
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
|
||||
];
|
||||
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
||||
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
|
||||
|
||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||
type McpClient = 'claude' | 'codex' | 'json';
|
||||
|
||||
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
|
||||
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
|
||||
// examples (settings → Webhooks, KI-Agenten & Export)
|
||||
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);
|
||||
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
||||
|
||||
// Live Webhook Simulator State
|
||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||
@@ -140,6 +150,26 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
"tags": ["smarthome", "keller"]
|
||||
}`;
|
||||
|
||||
const mcpUrl = `${baseUrl}/api/v1/mcp`;
|
||||
const mcpSnippet =
|
||||
mcpClient === 'claude'
|
||||
? `claude mcp add --transport http mindsift ${mcpUrl} \\
|
||||
--header "X-API-Key: ${exampleToken}"`
|
||||
: mcpClient === 'codex'
|
||||
? `# ~/.codex/config.toml
|
||||
[mcp_servers.mindsift]
|
||||
url = "${mcpUrl}"
|
||||
http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
: `{
|
||||
"mcpServers": {
|
||||
"mindsift": {
|
||||
"type": "http",
|
||||
"url": "${mcpUrl}",
|
||||
"headers": { "X-API-Key": "${exampleToken}" }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* API keys */}
|
||||
@@ -149,10 +179,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
|
||||
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</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.
|
||||
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
|
||||
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,27 +201,32 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
|
||||
{(
|
||||
[
|
||||
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
|
||||
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
|
||||
] as const
|
||||
).map(([group, heading, path]) => (
|
||||
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
|
||||
<p className="text-[11px] text-muted pt-1 pb-0.5 flex flex-wrap justify-between gap-x-2">
|
||||
<span className="font-semibold">{heading}</span>
|
||||
<code className="font-mono">{path}</code>
|
||||
</p>
|
||||
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
|
||||
const on = newKeyScopes.includes(sc.id);
|
||||
return (
|
||||
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
|
||||
<div className="min-w-0">
|
||||
<span className="block text-xs text-main">{sc.label}</span>
|
||||
<span className="block text-[11px] text-muted">{sc.hint}</span>
|
||||
</div>
|
||||
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -222,7 +257,12 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((sc) => (
|
||||
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
|
||||
<span
|
||||
key={sc}
|
||||
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
|
||||
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
|
||||
}`}
|
||||
>
|
||||
{scopeLabel(sc)}
|
||||
</span>
|
||||
))}
|
||||
@@ -245,6 +285,56 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI agents (MCP) */}
|
||||
<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">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||
<p className="text-xs text-muted">
|
||||
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten können offene
|
||||
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||
{(
|
||||
[
|
||||
['claude', 'Claude Code'],
|
||||
['codex', 'OpenAI Codex'],
|
||||
['json', 'Claude Desktop / Cursor'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mcpClient === value}
|
||||
onClick={() => setMcpClient(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">list_tasks</code>,{' '}
|
||||
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
|
||||
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</code>,{' '}
|
||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">search</code>,{' '}
|
||||
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
|
||||
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||
werden noch nicht unterstützt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Simulator */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
interface SectionPickerProps {
|
||||
value: DataSection[];
|
||||
@@ -8,30 +8,27 @@ interface SectionPickerProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Which parts of the own data a restore or import covers; at least one stays selected
|
||||
// Which parts of the own data a restore or import covers; one switch per section, at least one stays on
|
||||
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 role="group" aria-label={label}>
|
||||
<p className="text-[11px] text-muted mb-1.5">{label}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 border border-border rounded-card px-3 py-1.5 bg-panel">
|
||||
{DATA_SECTIONS.map((sec) => {
|
||||
const on = value.includes(sec.id);
|
||||
return (
|
||||
<div key={sec.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
|
||||
<span className="text-xs text-main">{sec.label}</span>
|
||||
<ToggleSwitch
|
||||
checked={on}
|
||||
title={sec.label}
|
||||
onChange={() => {
|
||||
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||
if (next.length > 0) onChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MessageSquare,
|
||||
Globe,
|
||||
Send,
|
||||
X,
|
||||
AlertCircle,
|
||||
Palette,
|
||||
Upload,
|
||||
@@ -50,7 +51,7 @@ import { SessionsPanel } from './SessionsPanel';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
|
||||
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
|
||||
import { currentPushSubscription, disablePush, enablePush, pushSupported, removePushDevice } from '../../lib/push';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
@@ -301,13 +302,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
// Web push: devices of this account and whether this browser is one of them
|
||||
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
|
||||
const [pushHere, setPushHere] = useState(false);
|
||||
const [pushEndpointHere, setPushEndpointHere] = useState<string | null>(null);
|
||||
const [pushBusy, setPushBusy] = useState(false);
|
||||
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const reloadPush = useCallback(async () => {
|
||||
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
|
||||
setPushDevices(devices);
|
||||
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
|
||||
setPushEndpointHere(sub?.endpoint ?? null);
|
||||
}, []);
|
||||
// An old installation or a device you no longer use: remove it from the list
|
||||
const removeDevice = async (endpoint: string) => {
|
||||
setPushBusy(true);
|
||||
setPushMsg(null);
|
||||
try {
|
||||
await removePushDevice(endpoint);
|
||||
await reloadPush();
|
||||
} catch (err) {
|
||||
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Gerät konnte nicht entfernt werden' });
|
||||
} finally {
|
||||
setPushBusy(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void reloadPush();
|
||||
}, [reloadPush]);
|
||||
@@ -566,7 +582,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
|
||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten & Daten-Backup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -635,8 +651,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
>
|
||||
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Private Webhooks & Export</p>
|
||||
<p className="text-[10px] text-faint">API-Keys, Curl & JSON-Backup</p>
|
||||
<p className="font-semibold">Webhooks, KI-Agenten & Export</p>
|
||||
<p className="text-[10px] text-faint">API-Keys, MCP, Curl & JSON-Backup</p>
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
@@ -1783,10 +1799,36 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{pushDevices.length > 0 && (
|
||||
<p className="pl-6 text-[11px] text-faint">
|
||||
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
|
||||
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
|
||||
</p>
|
||||
<div className="pl-6 space-y-1">
|
||||
<p className="text-[11px] text-faint">
|
||||
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen
|
||||
verschwinden beim nächsten Push von selbst oder lassen sich hier entfernen.
|
||||
</p>
|
||||
<ul className="border border-border divide-y divide-border bg-panel">
|
||||
{pushDevices.map((d) => (
|
||||
<li key={d.id} className="flex items-center justify-between gap-2 pl-2.5 pr-1 py-1">
|
||||
<span className="min-w-0 text-[11px] text-main truncate">
|
||||
{d.device || 'Gerät'}
|
||||
<span className="text-faint">
|
||||
{' · seit '}
|
||||
{new Date(d.createdAt).toLocaleDateString('de-DE')}
|
||||
{d.endpoint === pushEndpointHere && ' · dieses Gerät'}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeDevice(d.endpoint)}
|
||||
disabled={pushBusy || demoMode}
|
||||
title="Gerät entfernen"
|
||||
aria-label={`${d.device || 'Gerät'} entfernen`}
|
||||
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 flex items-center justify-center text-faint hover:text-status-err disabled:opacity-40"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{pushMsg && (
|
||||
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
|
||||
@@ -1813,11 +1855,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
Private Webhooks (Eingehend) & Daten-Export
|
||||
Webhooks, KI-Agenten & Daten-Export
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring
|
||||
oder Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
|
||||
Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken
|
||||
einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,18 +24,27 @@ describe('ApiKeysPanel', () => {
|
||||
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
|
||||
expect(screen.getAllByText('📋 Aufgaben anlegen').length).toBeGreaterThan(1); // switch label + badge
|
||||
});
|
||||
|
||||
it('creates a key with the chosen scopes and never without one', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
const triage = screen.getByRole('checkbox', { name: /Triage/ });
|
||||
const triage = screen.getByRole('switch', { 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/ }));
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Listeneinträge anlegen/ }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||
});
|
||||
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||
});
|
||||
|
||||
it('offers agent scopes and shows how to connect an MCP client', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Aufgaben ändern/ }));
|
||||
expect(screen.getByRole('switch', { name: /Aufgaben ändern/ }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { SectionPicker } from '../SectionPicker';
|
||||
|
||||
describe('SectionPicker', () => {
|
||||
it('uses switches and keeps at least one section on', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SectionPicker value={['notes']} onChange={onChange} />);
|
||||
const notes = screen.getByRole('switch', { name: 'Notizen' });
|
||||
expect(notes.getAttribute('aria-checked')).toBe('true');
|
||||
fireEvent.click(notes); // last one cannot be switched off
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Listen' }));
|
||||
expect(onChange).toHaveBeenCalledWith(['notes', 'lists']);
|
||||
});
|
||||
});
|
||||
@@ -761,11 +761,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
handleAction('keep', { convertTo: 'list_item', targetListId: singleWritableList.id });
|
||||
return;
|
||||
}
|
||||
setShowDateModal(false);
|
||||
setShowListSelector((prev) => !prev);
|
||||
// Phones: the list choice of the bottom sheet (the inline choice above is desktop-only)
|
||||
if (!activeCard) return;
|
||||
openActionSheetForCard(activeCard);
|
||||
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 touch-manipulation ${
|
||||
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : showListSelector ? 'border-accent bg-accent-dim' : ''
|
||||
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
@@ -784,10 +786,12 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowListSelector(false);
|
||||
setShowDateModal((prev) => !prev);
|
||||
// Phones: the date choice of the bottom sheet (the inline one above is desktop-only)
|
||||
if (!activeCard) return;
|
||||
openActionSheetForCard(activeCard);
|
||||
setSheetMode('date');
|
||||
}}
|
||||
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 touch-manipulation ${showDateModal ? 'border-accent bg-accent-dim' : ''}`}
|
||||
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 touch-manipulation"
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
|
||||
@@ -1372,23 +1372,28 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{notice && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||
className="fixed bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-1.5rem)] sm:w-auto sm:max-w-[90vw] flex flex-col sm:block pl-4 pr-1 pt-3 pb-1 sm:px-3 sm:py-2 rounded-card text-sm sm:text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||
>
|
||||
<span>{notice}</span>
|
||||
{noticeActions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setNoticeActions([]);
|
||||
a.run();
|
||||
}}
|
||||
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Phones: text on top (two lines at most), buttons below; desktop: one line */}
|
||||
<span className="max-sm:pr-3 max-sm:line-clamp-2">{notice}</span>
|
||||
{noticeActions.length > 0 && (
|
||||
<span className="flex justify-end sm:inline">
|
||||
{noticeActions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setNoticeActions([]);
|
||||
a.run();
|
||||
}}
|
||||
className="h-11 px-3 sm:h-auto sm:px-0 sm:ml-3 font-semibold text-accent hover:underline"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowDown, ArrowUp, IndentDecrease, IndentIncrease } from 'lucide-react
|
||||
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||
|
||||
// Phones and tablets have no Tab key: while a bullet is being edited, a slim bar sits right above the
|
||||
// on-screen keyboard with outdent / indent and move up / down (the bullet takes its children along).
|
||||
// on-screen keyboard (only while it is open) with outdent / indent and move up / down (the bullet takes its children along).
|
||||
export const isTouchOnly =
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches &&
|
||||
@@ -19,7 +19,7 @@ interface MobileOutlineBarProps {
|
||||
|
||||
export const MobileOutlineBar: React.FC<MobileOutlineBarProps> = ({ onOutdent, onIndent, onMoveUp, onMoveDown }) => {
|
||||
// iOS keeps fixed elements behind the keyboard: lift the bar by the keyboard's height
|
||||
const { inset } = useKeyboardInset();
|
||||
const { inset, keyboardOpen } = useKeyboardInset();
|
||||
|
||||
const button = (label: string, icon: React.ReactNode, action: () => void) => (
|
||||
<button
|
||||
@@ -36,6 +36,9 @@ export const MobileOutlineBar: React.FC<MobileOutlineBarProps> = ({ onOutdent, o
|
||||
</button>
|
||||
);
|
||||
|
||||
// Keyboard closed (e.g. back button) while the bullet keeps its focus: no bar over the bottom navigation
|
||||
if (!keyboardOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed left-0 right-0 z-[70] flex items-center justify-center gap-2 px-3 py-1.5 bg-panel/95 backdrop-blur-md border-t border-border"
|
||||
|
||||
@@ -42,6 +42,7 @@ import { mediaContent, parseMediaContent } from '../../lib/media';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
import { safeHref } from '../../lib/safeUrl';
|
||||
import { isNoteLinkOnly } from '../../lib/wikiLink';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link' | 'dictation' | 'extract';
|
||||
@@ -459,7 +460,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
// /extract only when note has content and is at start/end
|
||||
if (c.type === 'extract') {
|
||||
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
|
||||
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock && !isNoteLinkOnly(restOfBlock);
|
||||
}
|
||||
|
||||
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
|
||||
@@ -1579,7 +1580,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
|
||||
activeFocus
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||
: // Hover only with a real pointer: a tap on a phone would make the invisible buttons clickable
|
||||
'opacity-0 [@media(hover:hover)]:group-hover:opacity-100 focus-within:opacity-100 pointer-events-none [@media(hover:hover)]:group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||
}`}
|
||||
>
|
||||
{/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}
|
||||
@@ -1702,7 +1704,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
)}
|
||||
|
||||
{/* Branch into its own note */}
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !isNoteLinkOnly(localContent) && !mediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExtractBlock(block.id)}
|
||||
|
||||
@@ -562,14 +562,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}, [currentUser, reloadTrash]);
|
||||
|
||||
const handleCreateDocument = useCallback(
|
||||
async (folderPath = '') => {
|
||||
async (folderPath = '', title = 'Neue Notiz', icon = '📝') => {
|
||||
if (isCreatingRef.current) return;
|
||||
isCreatingRef.current = true;
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
const created = await api.createDocument({
|
||||
title: 'Neue Notiz',
|
||||
icon: '📝',
|
||||
title: title.trim() || 'Neue Notiz',
|
||||
icon: icon.trim() || '📝',
|
||||
folderPath,
|
||||
blocks: [
|
||||
{
|
||||
@@ -602,6 +602,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
[onStatsChange]
|
||||
);
|
||||
|
||||
// Phones: "+" asks for name and emoji first, so a stray tap does not leave an empty note behind.
|
||||
// The desktop keeps creating at once.
|
||||
const [newNoteSheet, setNewNoteSheet] = useState<{ folderPath: string } | null>(null);
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteIcon, setNewNoteIcon] = useState('📝');
|
||||
const openNewNote = useCallback(
|
||||
(folderPath = '') => {
|
||||
if (window.innerWidth >= 640) {
|
||||
void handleCreateDocument(folderPath);
|
||||
return;
|
||||
}
|
||||
setNewNoteTitle('');
|
||||
setNewNoteIcon('📝');
|
||||
setNewNoteSheet({ folderPath });
|
||||
},
|
||||
[handleCreateDocument]
|
||||
);
|
||||
|
||||
const handleJumpToDocBlock = useCallback((docId: string, blockId?: string) => {
|
||||
setSelectedDocId(docId);
|
||||
setTargetBlockId(blockId || null);
|
||||
@@ -1142,7 +1160,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
// Register mobile FAB actions and the menu at the top; on phones the menu also switches between
|
||||
// notes, to-dos and hashtags (like the lists in Tasks and Shopping lists), the tab row is hidden there
|
||||
useEffect(() => {
|
||||
setPrimaryAction(() => handleCreateDocument(''));
|
||||
setPrimaryAction(() => openNewNote(''));
|
||||
setGroups([
|
||||
{
|
||||
id: 'workspace-views',
|
||||
@@ -1191,7 +1209,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
label: 'Neue Notiz',
|
||||
icon: <FilePlus className="w-4 h-4" />,
|
||||
variant: 'accent',
|
||||
onClick: () => handleCreateDocument(''),
|
||||
onClick: () => openNewNote(''),
|
||||
},
|
||||
{
|
||||
id: 'new-folder',
|
||||
@@ -1213,7 +1231,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setGroups([]);
|
||||
setPrimaryAction(undefined);
|
||||
};
|
||||
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash, subView, totalOpenTodos, tagsSummary.length]);
|
||||
}, [handleCreateDocument, openNewNote, setGroups, setPrimaryAction, trash.length, openTrash, subView, totalOpenTodos, tagsSummary.length]);
|
||||
|
||||
// Real-time matched blocks and all nested children for the selected hashtag
|
||||
const activeTagGroups = useMemo(() => {
|
||||
@@ -2735,7 +2753,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateDocument('')}
|
||||
onClick={() => openNewNote('')}
|
||||
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@@ -2766,6 +2784,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Phones: name and emoji of a new note before it is created */}
|
||||
{newNoteSheet && (
|
||||
<FormSheet
|
||||
title="Neue Notiz"
|
||||
icon={<FilePlus className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setNewNoteSheet(null)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { folderPath } = newNoteSheet;
|
||||
setNewNoteSheet(null);
|
||||
void handleCreateDocument(folderPath, newNoteTitle, newNoteIcon);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newNoteIcon}
|
||||
onChange={(e) => setNewNoteIcon(e.target.value)}
|
||||
aria-label="Emoji"
|
||||
title="Icon (Emoji)"
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newNoteTitle}
|
||||
onChange={(e) => setNewNoteTitle(e.target.value)}
|
||||
placeholder="Name der Notiz"
|
||||
className="input-field flex-1 min-w-0 h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{mergeDialog && (
|
||||
<MergeDialog
|
||||
docs={docs}
|
||||
|
||||
@@ -47,3 +47,28 @@ describe('outbox', () => {
|
||||
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list items added offline', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('show up as stand-ins and follow edits until they are sent', async () => {
|
||||
const { pendingListItems, updateQueued, dropQueued, OFFLINE_ITEM_PREFIX } = await import('../offline');
|
||||
const milk = enqueue('POST', '/api/v1/lists/lst_1/items', { content: 'Hafermilch', category: 'Kühlregal', quantity: '' });
|
||||
const bread = enqueue('POST', '/api/v1/lists/lst_2/items', { content: 'Brot', category: '', quantity: '1' });
|
||||
enqueue('POST', '/api/v1/inbox', { content: 'kein Listeneintrag' });
|
||||
|
||||
let items = pendingListItems();
|
||||
expect(items.map((i) => [i.listId, i.content])).toEqual([
|
||||
['lst_1', 'Hafermilch'],
|
||||
['lst_2', 'Brot'],
|
||||
]);
|
||||
expect(items[0].id).toBe(OFFLINE_ITEM_PREFIX + milk.id);
|
||||
|
||||
updateQueued(milk.id, { quantity: '2' });
|
||||
dropQueued(bread.id);
|
||||
items = pendingListItems();
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].quantity).toBe('2');
|
||||
expect(getOutbox()).toHaveLength(2); // the inbox entry stays
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,30 @@ describe('useKeyboardInset', () => {
|
||||
const vv = mockVisualViewport(844);
|
||||
const { result } = renderHook(() => useKeyboardInset());
|
||||
expect(result.current.inset).toBe(0);
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
act(() => vv.open(336));
|
||||
expect(result.current.inset).toBe(336);
|
||||
expect(result.current.visibleHeight).toBe(508);
|
||||
expect(result.current.keyboardOpen).toBe(true);
|
||||
act(() => vv.open(0));
|
||||
expect(result.current.inset).toBe(0);
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('notices the keyboard also when the page shrinks instead (no inset)', () => {
|
||||
const vv = mockVisualViewport(844);
|
||||
const { result } = renderHook(() => useKeyboardInset());
|
||||
act(() => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 508, configurable: true });
|
||||
vv.open(336);
|
||||
});
|
||||
expect(result.current.inset).toBe(0);
|
||||
expect(result.current.keyboardOpen).toBe(true);
|
||||
// Browser bars appearing are not a keyboard
|
||||
act(() => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 788, configurable: true });
|
||||
vv.open(56);
|
||||
});
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
let statusListener: ((connected: boolean) => void) | null = null;
|
||||
let currentConnected = false;
|
||||
vi.mock('../api', () => ({
|
||||
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
||||
statusListener = cb;
|
||||
cb(currentConnected);
|
||||
return () => (statusListener = null);
|
||||
},
|
||||
}));
|
||||
|
||||
import { useServerOnline, SERVER_OFFLINE_GRACE_MS } from '../useServerOnline';
|
||||
|
||||
function setDeviceOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
||||
}
|
||||
|
||||
describe('useServerOnline', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
currentConnected = false;
|
||||
setDeviceOnline(true);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('stays online during the grace period at app start', () => {
|
||||
const { result } = renderHook(() => useServerOnline());
|
||||
expect(result.current).toBe(true);
|
||||
act(() => statusListener?.(true));
|
||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('turns offline once the server stays unreachable and back online on reconnect', () => {
|
||||
currentConnected = true;
|
||||
const { result } = renderHook(() => useServerOnline());
|
||||
act(() => statusListener?.(false));
|
||||
expect(result.current).toBe(true);
|
||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
||||
expect(result.current).toBe(false);
|
||||
act(() => statusListener?.(true));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('repeated failed reconnects do not postpone the offline state', () => {
|
||||
currentConnected = true;
|
||||
const { result } = renderHook(() => useServerOnline());
|
||||
for (let i = 0; i < 4; i++) {
|
||||
act(() => statusListener?.(false));
|
||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS / 2));
|
||||
}
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('is offline right away without device network', () => {
|
||||
currentConnected = true;
|
||||
const { result } = renderHook(() => useServerOnline());
|
||||
act(() => {
|
||||
setDeviceOnline(false);
|
||||
statusListener?.(false);
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { joinSpeechResults } from '../voice';
|
||||
|
||||
describe('joinSpeechResults (browser speech recognition)', () => {
|
||||
it('Android: growing interim states of one phrase count once', () => {
|
||||
expect(joinSpeechResults(['Test', 'Test', 'Test', 'Test', 'Test', 'Test 1 2 3'])).toBe('Test 1 2 3');
|
||||
expect(joinSpeechResults(['Test', 'Test 1', 'Test 1 2', 'Test 1 2 3'])).toBe('Test 1 2 3');
|
||||
});
|
||||
|
||||
it('desktop: consecutive pieces are joined', () => {
|
||||
expect(joinSpeechResults(['Milch kaufen', ' und Brot'])).toBe('Milch kaufen und Brot');
|
||||
});
|
||||
|
||||
it('Android: a new phrase after a finished one is appended', () => {
|
||||
expect(joinSpeechResults(['Milch', 'Milch kaufen', ' und Brot', ' und Brot holen'])).toBe('Milch kaufen und Brot holen');
|
||||
});
|
||||
|
||||
it('ignores empty results and earlier shorter states', () => {
|
||||
expect(joinSpeechResults(['', 'Test 1 2', 'Test 1', ''])).toBe('Test 1 2');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||
import { buildBlockLink, isNoteLinkOnly, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||
|
||||
describe('wiki links', () => {
|
||||
it('parses plain, labelled, section and bullet links', () => {
|
||||
@@ -18,4 +18,12 @@ describe('wiki links', () => {
|
||||
expect(parsed.blockId).toBe('blk_x');
|
||||
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
|
||||
});
|
||||
|
||||
it('recognises a bullet that is only a link to a note', () => {
|
||||
expect(isNoteLinkOnly('[[Konzept: Garten]]')).toBe(true);
|
||||
expect(isNoteLinkOnly('[[Konzept: Garten]] #draußen #idee')).toBe(true);
|
||||
expect(isNoteLinkOnly('Siehe [[Konzept: Garten]]')).toBe(false);
|
||||
expect(isNoteLinkOnly('[[Konzept: Garten]] und mehr')).toBe(false);
|
||||
expect(isNoteLinkOnly('Garten')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,9 @@ import {
|
||||
clearOfflineData,
|
||||
enqueue,
|
||||
flushOutbox,
|
||||
dropQueued,
|
||||
updateQueued,
|
||||
OFFLINE_ITEM_PREFIX,
|
||||
} from './offline';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -271,12 +274,13 @@ export const api = {
|
||||
password: string,
|
||||
displayName: string,
|
||||
inviteCode?: string
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
||||
): Promise<{ token?: string; user?: User; pending?: boolean; message?: string }> {
|
||||
const res = await request<{ token?: string; user?: User; pending?: boolean; message?: string }>('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
||||
});
|
||||
setToken(res.token);
|
||||
// Waiting for approval: no session yet
|
||||
if (res.token) setToken(res.token);
|
||||
return res;
|
||||
},
|
||||
|
||||
@@ -734,8 +738,8 @@ export const api = {
|
||||
return res.devices || [];
|
||||
},
|
||||
|
||||
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
|
||||
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
|
||||
async subscribePush(sub: PushSubscriptionJSON, device: string, replaces?: string): Promise<void> {
|
||||
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device, replaces: replaces || '' }) });
|
||||
},
|
||||
|
||||
async unsubscribePush(endpoint: string): Promise<void> {
|
||||
@@ -960,13 +964,32 @@ export const api = {
|
||||
},
|
||||
|
||||
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, category, quantity }),
|
||||
});
|
||||
const path = `/api/v1/lists/${listId}/items`;
|
||||
try {
|
||||
return await request<ListItem>(path, { method: 'POST', body: JSON.stringify({ content, category, quantity }) });
|
||||
} catch (err) {
|
||||
if (!(err instanceof OfflineError)) throw err;
|
||||
// Kept in the outbox and sent when the connection is back; until then a stand-in in the list
|
||||
const entry = enqueue('POST', path, { content, category, quantity });
|
||||
return {
|
||||
id: OFFLINE_ITEM_PREFIX + entry.id,
|
||||
listId,
|
||||
content,
|
||||
category,
|
||||
quantity,
|
||||
isCompleted: false,
|
||||
sortOrder: 0,
|
||||
createdAt: entry.createdAt,
|
||||
} as ListItem;
|
||||
}
|
||||
},
|
||||
|
||||
async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
|
||||
// Added offline and checked off before it was sent: nothing left to send
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX) && isCompleted) {
|
||||
dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length));
|
||||
return { id: itemId, listId, isCompleted: true } as ListItem;
|
||||
}
|
||||
try {
|
||||
return await request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -984,6 +1007,10 @@ export const api = {
|
||||
itemId: string,
|
||||
payload: { content?: string; category?: string; quantity?: string }
|
||||
): Promise<ListItem> {
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||
updateQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length), payload);
|
||||
return { id: itemId, listId, ...payload } as ListItem;
|
||||
}
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -991,6 +1018,10 @@ export const api = {
|
||||
},
|
||||
|
||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||
if (itemId.startsWith(OFFLINE_ITEM_PREFIX)) {
|
||||
dropQueued(itemId.slice(OFFLINE_ITEM_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -1121,6 +1152,7 @@ export const api = {
|
||||
email?: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
maxUses?: number;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
@@ -1128,6 +1160,22 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async verifyEmail(token: string): Promise<{ verified: boolean; pending: boolean; message: string }> {
|
||||
return request('/api/v1/auth/verify-email', { method: 'POST', body: JSON.stringify({ token }) });
|
||||
},
|
||||
|
||||
async resendVerification(email: string): Promise<void> {
|
||||
await request('/api/v1/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
},
|
||||
|
||||
async adminSendInvite(id: string, email: string): Promise<UserInvite> {
|
||||
return request<UserInvite>(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) });
|
||||
},
|
||||
|
||||
async adminApproveUser(id: string): Promise<User> {
|
||||
return request<User>(`/api/v1/admin/users/${id}/approve`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async adminDeleteInvite(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -154,6 +154,43 @@ export function enqueue(method: OutboxEntry['method'], path: string, body: Recor
|
||||
|
||||
export const outboxCount = () => getOutbox().length;
|
||||
|
||||
/** Changes the body of a queued entry (e.g. the quantity of a list item added offline). */
|
||||
export function updateQueued(entryId: string, body: Record<string, unknown>): boolean {
|
||||
const entries = getOutbox();
|
||||
const entry = entries.find((e) => e.id === entryId);
|
||||
if (!entry) return false;
|
||||
entry.body = { ...entry.body, ...body };
|
||||
saveOutbox(entries);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes a queued entry that is no longer wanted (e.g. a list item added and deleted while offline). */
|
||||
export function dropQueued(entryId: string): boolean {
|
||||
const entries = getOutbox();
|
||||
const rest = entries.filter((e) => e.id !== entryId);
|
||||
if (rest.length === entries.length) return false;
|
||||
saveOutbox(rest);
|
||||
return true;
|
||||
}
|
||||
|
||||
// List items added offline: a stand-in id until the server knows them, derived from the outbox entry
|
||||
export const OFFLINE_ITEM_PREFIX = 'itm_offline_';
|
||||
const LIST_ITEMS_PATH = /^\/api\/v1\/lists\/([^/]+)\/items$/;
|
||||
|
||||
/** List items still waiting in the outbox, as stand-ins for the list view (also after a reload). */
|
||||
export function pendingListItems(): { listId: string; id: string; content: string; category: string; quantity: string; createdAt: string }[] {
|
||||
return getOutbox()
|
||||
.filter((e) => e.method === 'POST' && LIST_ITEMS_PATH.test(e.path))
|
||||
.map((e) => ({
|
||||
listId: (e.path.match(LIST_ITEMS_PATH) as RegExpMatchArray)[1],
|
||||
id: OFFLINE_ITEM_PREFIX + e.id,
|
||||
content: String(e.body.content || ''),
|
||||
category: String(e.body.category || ''),
|
||||
quantity: String(e.body.quantity || ''),
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
let flushing: Promise<number> | null = null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,7 +64,40 @@ export async function enablePush(): Promise<void> {
|
||||
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
|
||||
}
|
||||
}
|
||||
await api.subscribePush(sub.toJSON(), deviceName());
|
||||
// The address this browser registered before is dead once it has a new one (e.g. after reinstalling
|
||||
// the app): the server drops it right away instead of counting it as another device
|
||||
const previous = readStoredEndpoint();
|
||||
await api.subscribePush(sub.toJSON(), deviceName(), previous && previous !== sub.endpoint ? previous : undefined);
|
||||
storeEndpoint(sub.endpoint);
|
||||
}
|
||||
|
||||
const ENDPOINT_KEY = 'mindsift_push_endpoint';
|
||||
|
||||
function readStoredEndpoint(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ENDPOINT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeEndpoint(endpoint: string | null): void {
|
||||
try {
|
||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
else localStorage.removeItem(ENDPOINT_KEY);
|
||||
} catch {
|
||||
// storage blocked: the dead device is removed on the next push instead
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes one of the own devices (e.g. an old installation) on the server. */
|
||||
export async function removePushDevice(endpoint: string): Promise<void> {
|
||||
const here = await currentPushSubscription();
|
||||
if (here?.endpoint === endpoint) {
|
||||
await disablePush();
|
||||
return;
|
||||
}
|
||||
await api.unsubscribePush(endpoint);
|
||||
}
|
||||
|
||||
export async function disablePush(): Promise<void> {
|
||||
@@ -73,4 +106,5 @@ export async function disablePush(): Promise<void> {
|
||||
const endpoint = sub.endpoint;
|
||||
await sub.unsubscribe().catch(() => false);
|
||||
await api.unsubscribePush(endpoint).catch(() => {});
|
||||
storeEndpoint(null);
|
||||
}
|
||||
@@ -397,6 +397,14 @@ export function getActiveTheme(): ThemeId {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
/** True once a theme was chosen on this device (the default 'dark' is not stored until then) */
|
||||
export function hasChosenTheme(): boolean {
|
||||
return load(THEME_STORAGE_KEY) !== null;
|
||||
}
|
||||
|
||||
// The demo starts with a round theme, so visitors see the rounded look; a chosen theme stays
|
||||
export const DEMO_THEME: ThemeId = 'fjord-night';
|
||||
|
||||
// customCss is required for custom themes and ignored for built-in ones
|
||||
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
|
||||
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
|
||||
|
||||
@@ -2,9 +2,16 @@ export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type UserRole = 'admin' | 'member';
|
||||
|
||||
export type RegistrationMode = 'closed' | 'approval' | 'domain' | 'domain+approval' | 'open';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
// Registering without an invite and the e-mail domains of the domain modes (hint on the form)
|
||||
registrationMode?: RegistrationMode;
|
||||
registrationDomains?: string[];
|
||||
// New accounts confirm their e-mail address by link first
|
||||
emailVerification?: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
sharingMode?: SharingMode;
|
||||
@@ -94,6 +101,10 @@ export interface User {
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
// Registered and waiting for an admin's approval (admin user list)
|
||||
pendingApproval?: boolean;
|
||||
// Registered, e-mail address not confirmed yet (admin user list)
|
||||
emailUnverified?: boolean;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
@@ -115,6 +126,9 @@ export interface UserInvite {
|
||||
usedBy?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
// A link for several people: allowed and used redemptions
|
||||
maxUses: number;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
@@ -250,6 +264,14 @@ export interface SystemSettings {
|
||||
smtpSkipVerify?: boolean;
|
||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
registrationMode?: RegistrationMode;
|
||||
registrationDomains?: string;
|
||||
// REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true) fix it: shown, not editable
|
||||
registrationConfiguredViaEnv?: boolean;
|
||||
registrationVerifyEmail?: boolean;
|
||||
registrationVerifyConfiguredViaEnv?: boolean;
|
||||
// Mails with links can go out (mail server and BASE_URL configured)
|
||||
emailLinksAvailable?: boolean;
|
||||
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
|
||||
backupEnabled?: boolean;
|
||||
backupTime?: string;
|
||||
@@ -570,8 +592,15 @@ export interface UserTheme {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** What an API key may deliver; keys never read data. */
|
||||
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
|
||||
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists over MCP. */
|
||||
export type ApiKeyScope =
|
||||
| 'webhook:triage'
|
||||
| 'webhook:task'
|
||||
| 'webhook:list'
|
||||
| 'tasks:read'
|
||||
| 'tasks:write'
|
||||
| 'lists:read'
|
||||
| 'lists:write';
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Space the on-screen keyboard takes at the bottom (0 without keyboard) and the height that stays
|
||||
// visible. Fixed bottom sheets use it to sit right above the keyboard instead of behind it (iOS keeps
|
||||
// fixed elements on the layout viewport; on Android the inset is usually 0 because the page shrinks).
|
||||
export function useKeyboardInset(): { inset: number; visibleHeight: number } {
|
||||
// Tallest visible height seen at the current width, i.e. the height without keyboard. Tracked from app
|
||||
// start, so it is known before the first field is focused; a new width (rotation) starts over.
|
||||
let fullHeight = 0;
|
||||
let fullWidth = 0;
|
||||
function noteFullHeight() {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
if (window.innerWidth !== fullWidth) {
|
||||
fullWidth = window.innerWidth;
|
||||
fullHeight = 0;
|
||||
}
|
||||
fullHeight = Math.max(fullHeight, Math.round(vv.height));
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||
noteFullHeight();
|
||||
window.visualViewport.addEventListener('resize', noteFullHeight);
|
||||
}
|
||||
|
||||
// More than browser bars appearing or disappearing (about 60 px)
|
||||
const KEYBOARD_MIN_PX = 150;
|
||||
|
||||
// Space the on-screen keyboard takes at the bottom (0 without keyboard), the height that stays visible
|
||||
// and whether a keyboard is open. Fixed bottom sheets use the inset to sit right above the keyboard
|
||||
// instead of behind it (iOS keeps fixed elements on the layout viewport; on Android the inset is usually
|
||||
// 0 because the page shrinks). keyboardOpen compares with the height without keyboard, so it works in
|
||||
// both cases; without visualViewport it is unknown and reported as open.
|
||||
export function useKeyboardInset(): { inset: number; visibleHeight: number; keyboardOpen: boolean } {
|
||||
const read = () => {
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0 };
|
||||
return { inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), visibleHeight: Math.round(vv.height) };
|
||||
if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0, keyboardOpen: true };
|
||||
noteFullHeight();
|
||||
const visibleHeight = Math.round(vv.height);
|
||||
return {
|
||||
inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)),
|
||||
visibleHeight,
|
||||
keyboardOpen: visibleHeight < fullHeight - KEYBOARD_MIN_PX,
|
||||
};
|
||||
};
|
||||
const [state, setState] = useState(read);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onRealtimeStatus } from './api';
|
||||
|
||||
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
||||
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
||||
|
||||
// Whether the server is reachable, judged by the app-wide realtime connection (WebSocket/SSE). Unlike
|
||||
// navigator.onLine it also notices a server that is down while the device still has network. Without
|
||||
// device network it is offline right away; otherwise only after the connection stayed away for a moment.
|
||||
export function useServerOnline(): boolean {
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let connected = false;
|
||||
let timer: number | undefined;
|
||||
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
||||
const update = () => {
|
||||
if (connected) {
|
||||
window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
setOnline(true);
|
||||
} else if (navigator.onLine === false) {
|
||||
window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
setOnline(false);
|
||||
} else if (timer === undefined) {
|
||||
timer = window.setTimeout(() => setOnline(false), SERVER_OFFLINE_GRACE_MS);
|
||||
}
|
||||
};
|
||||
const unsubscribe = onRealtimeStatus((isConnected) => {
|
||||
connected = isConnected;
|
||||
update();
|
||||
});
|
||||
window.addEventListener('online', update);
|
||||
window.addEventListener('offline', update);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', update);
|
||||
window.removeEventListener('offline', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, loadVoiceConfig, VoiceRecorder } from './voice';
|
||||
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, joinSpeechResults, loadVoiceConfig, VoiceRecorder } from './voice';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'transcribing';
|
||||
|
||||
@@ -234,8 +234,9 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = continuous || live;
|
||||
recognition.onresult = (event: any) => {
|
||||
let sessionText = '';
|
||||
for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < event.results.length; i++) parts.push(event.results[i][0].transcript);
|
||||
const sessionText = joinSpeechResults(parts);
|
||||
if (live && baseTranscriptRef.current) {
|
||||
const base = baseTranscriptRef.current.trimEnd();
|
||||
const next = sessionText.trimStart();
|
||||
|
||||
@@ -186,3 +186,27 @@ export class VoiceRecorder {
|
||||
this.analyser = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text of one speech recognition session from its results. Desktop browsers deliver consecutive
|
||||
* pieces ("Test 1 2 3", " und weiter"); Chrome on Android also keeps the growing interim states of
|
||||
* the same phrase as results of their own ("Test", "Test", "Test 1 2 3"). A result that only
|
||||
* continues the one before it therefore replaces it instead of being appended.
|
||||
*/
|
||||
export function joinSpeechResults(transcripts: string[]): string {
|
||||
const norm = (s: string) => s.trim().toLowerCase();
|
||||
let text = '';
|
||||
let last = '';
|
||||
for (const t of transcripts) {
|
||||
if (!t) continue;
|
||||
if (last && norm(t).startsWith(norm(last))) {
|
||||
text = text.slice(0, text.length - last.length) + t; // grown version of the same phrase
|
||||
} else if (last && norm(last).startsWith(norm(t))) {
|
||||
continue; // an earlier, shorter state of it
|
||||
} else {
|
||||
text += t;
|
||||
}
|
||||
last = t;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -31,3 +31,8 @@ export function buildBlockLink(title: string, blockId: string, blockContent: str
|
||||
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
|
||||
return `[[${clean(title)}#^${blockId}|${label}]]`;
|
||||
}
|
||||
|
||||
/** A bullet that is only a link to a note (plus hashtags), as left behind when a branch became a note. */
|
||||
export function isNoteLinkOnly(content: string): boolean {
|
||||
return /^\s*\[\[[^\]]+\]\](\s+#[\p{L}\p{N}_-]+)*\s*$/u.test(content);
|
||||
}
|
||||
@@ -76,6 +76,9 @@ func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.notifyEmailHook != nil { // tests
|
||||
return s.notifyEmailHook(toEmail, subject, bodyText, html)
|
||||
}
|
||||
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
|
||||
step := ""
|
||||
if se, ok := err.(*smtpStepError); ok {
|
||||
|
||||
@@ -270,6 +270,9 @@ func (s *Server) startCompaction() {
|
||||
|
||||
func (s *Server) compactOnce() {
|
||||
s.leases.Sweep()
|
||||
if n := s.store.PurgeUnverifiedAccounts(time.Now()); n > 0 {
|
||||
slog.Info("Unbestätigte Registrierungen gelöscht", "count", n)
|
||||
}
|
||||
s.compactMedia(time.Now())
|
||||
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
|
||||
st, err := s.store.CompactDocs(time.Now())
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
@@ -93,6 +94,12 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pending := s.store.PendingRegistrationIDs()
|
||||
unverified := s.store.UnverifiedEmailIDs()
|
||||
for i := range users {
|
||||
users[i].PendingApproval = pending[users[i].ID]
|
||||
users[i].EmailUnverified = unverified[users[i].ID]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
@@ -191,6 +198,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if req.IsActive != nil && !*req.IsActive {
|
||||
s.wsHub.DisconnectUser(targetUserID)
|
||||
}
|
||||
if req.IsActive != nil && *req.IsActive {
|
||||
// Activating by hand counts as approval and vouches for the address
|
||||
s.store.ClearPendingRegistration(targetUserID)
|
||||
s.store.ClearEmailVerification(targetUserID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
@@ -247,6 +259,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(targetUserID)
|
||||
s.store.ClearPendingRegistration(targetUserID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -270,6 +283,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
MaxUses int `json:"maxUses"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -278,7 +292,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
req.Role = "member"
|
||||
}
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays, req.MaxUses)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -312,6 +326,21 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
||||
out.SMTPPassSet = out.SMTPPass != ""
|
||||
out.SMTPPass = ""
|
||||
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
// What the environment fixes is shown as in effect and locked in the form
|
||||
if s.cfg.RegistrationMode != "" {
|
||||
out.RegistrationMode = s.cfg.RegistrationMode
|
||||
out.AllowRegistration = out.RegistrationMode == storage.RegistrationOpen
|
||||
out.RegistrationConfiguredViaEnv = true
|
||||
}
|
||||
if s.cfg.RegistrationDomains != "" {
|
||||
out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ")
|
||||
out.RegistrationConfiguredViaEnv = true
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||
out.RegistrationVerifyViaEnv = true
|
||||
}
|
||||
out.EmailLinksAvailable = s.emailLinksAvailable()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -327,6 +356,22 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
settings.SMTPPass = cur.SMTPPass
|
||||
}
|
||||
}
|
||||
// Registration fixed by the environment cannot be changed here (the stored value stays as it was)
|
||||
if s.cfg.RegistrationMode != "" || s.cfg.RegistrationDomains != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
if s.cfg.RegistrationMode != "" {
|
||||
settings.RegistrationMode, settings.AllowRegistration = cur.RegistrationMode, cur.AllowRegistration
|
||||
}
|
||||
if s.cfg.RegistrationDomains != "" {
|
||||
settings.RegistrationDomains = cur.RegistrationDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail
|
||||
}
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -29,21 +29,25 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
count, _ := s.store.CountUsers()
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
allowReg := false
|
||||
policy := s.registrationPolicy()
|
||||
instanceName := "MindSift"
|
||||
if sys != nil {
|
||||
allowReg = sys.AllowRegistration
|
||||
if sys.InstanceName != "" {
|
||||
instanceName = sys.InstanceName
|
||||
}
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"needsSetup": count == 0,
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
"needsSetup": count == 0,
|
||||
// Registering without an invite: allowed at all, how (closed | approval | domain | domain+approval |
|
||||
// open) and for which e-mail domains (shown as a hint on the sign-up form)
|
||||
"allowRegistration": policy.Mode != storage.RegistrationClosed,
|
||||
"registrationMode": policy.Mode,
|
||||
"registrationDomains": policy.Domains,
|
||||
"emailVerification": policy.VerifyEmail,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
|
||||
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
@@ -131,6 +135,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
sys.RegistrationMode = storage.RegistrationClosed
|
||||
if req.AllowRegistration {
|
||||
sys.RegistrationMode = storage.RegistrationOpen
|
||||
}
|
||||
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
||||
if err := s.store.UpdateSystemSettings(sys); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error())
|
||||
@@ -191,6 +199,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
count, _ := s.store.CountUsers()
|
||||
role := "member"
|
||||
var validInvite *domain.UserInvite
|
||||
pending := false // registered without invite while approval is needed
|
||||
verify := false // registered without invite while the address must be confirmed
|
||||
|
||||
if strings.TrimSpace(req.InviteCode) != "" {
|
||||
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
|
||||
@@ -207,9 +217,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
} else if count == 0 {
|
||||
role = "admin"
|
||||
} else {
|
||||
sysSettings, _ := s.store.GetSystemSettings()
|
||||
if sysSettings == nil || !sysSettings.AllowRegistration {
|
||||
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
||||
policy := s.registrationPolicy()
|
||||
var err error
|
||||
pending, err = policy.registrationOutcome(req.Email)
|
||||
verify = policy.VerifyEmail
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if pending && len(s.store.PendingRegistrationIDs()) >= storage.MaxPendingRegistrations {
|
||||
writeError(w, http.StatusTooManyRequests, "Gerade warten zu viele Registrierungen auf Freigabe – bitte später erneut versuchen")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -248,6 +265,39 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Waiting for the e-mail confirmation and/or approval: the account exists but stays locked, no session
|
||||
if pending || verify {
|
||||
inactive := false
|
||||
if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
if pending {
|
||||
if err := s.store.AddPendingRegistration(user.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
}
|
||||
message := "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst."
|
||||
if verify {
|
||||
// Admins hear about it only once the address is confirmed
|
||||
go s.sendVerificationMail(user)
|
||||
message = "Fast geschafft: Wir haben dir eine E-Mail an " + user.Email + " geschickt. Bestätige deine Adresse über den Link darin."
|
||||
if pending {
|
||||
message += " Danach schaltet ein Administrator dein Konto frei."
|
||||
}
|
||||
} else {
|
||||
go s.notifyAdminsOfRegistration(user)
|
||||
}
|
||||
slog.Info("Registrierung wartet", "user_id", user.ID, "email", user.Email, "approval", pending, "verify", verify)
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"pending": true,
|
||||
"verify": verify,
|
||||
"message": message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
@@ -310,6 +360,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.loginGuard.recordSuccess(account, ip)
|
||||
if !user.IsActive && s.store.IsEmailUnverified(user.ID) {
|
||||
writeError(w, http.StatusForbidden, "Bitte bestätige zuerst deine E-Mail-Adresse über den Link, den wir dir geschickt haben")
|
||||
return
|
||||
}
|
||||
if !user.IsActive && s.store.IsPendingRegistration(user.ID) {
|
||||
writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator")
|
||||
return
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
@@ -187,49 +188,72 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// createTask creates a task directly (webhook scope webhook:task, MCP tool create_task), optionally in
|
||||
// a task list the user may write to (otherwise storage.ErrTaskListReadOnly).
|
||||
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) {
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
return nil, storage.ErrTaskListReadOnly
|
||||
}
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", notes)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "")
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erforderlich")
|
||||
|
||||
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
||||
// viewers and "not found" for lists the user cannot see.
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string) (*domain.ListItem, error) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
return nil, errListIDRequired
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if errors.Is(err, storage.ErrListReadOnly) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrListReadOnly):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, errListIDRequired):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
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})
|
||||
}
|
||||
@@ -166,6 +166,25 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*domain.ListItem, error) {
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
item, err := s.store.ToggleListItem(itemID, done, userID, userName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
@@ -182,19 +201,11 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
|
||||
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -231,46 +231,40 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
// taskPatch is a partial change of a task; nil fields stay as they are. Shared by PATCH /thoughts/{id}
|
||||
// and the MCP tools.
|
||||
type taskPatch struct {
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
}
|
||||
|
||||
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
||||
// and sends the notifications. Errors come from the store (writeStoreError maps them).
|
||||
func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Thought, error) {
|
||||
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
||||
// The rule is set first, so completing a task in the same request already repeats it
|
||||
if req.Recurrence != nil {
|
||||
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
if req.Assignees != nil {
|
||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
updated.Assignees = assignees
|
||||
}
|
||||
@@ -281,6 +275,23 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
||||
s.notifyTaskChange(userID, before, updated, false)
|
||||
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
|
||||
var req taskPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
updated, err := s.updateTask(userID, thoughtID, req)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// MCP (Model Context Protocol) endpoint for AI agents: Streamable HTTP, stateless, JSON-RPC 2.0 over
|
||||
// POST. Authentication runs through authMiddleware (API key with agent scopes or a session); every
|
||||
// tool checks the scope it needs (mcpToolAllowed). There is no server-to-client stream (GET answers
|
||||
// 405), so each request is one JSON response.
|
||||
|
||||
const mcpPath = "/api/v1/mcp"
|
||||
|
||||
// Version of the tool set, not of MindSift (the binary does not know its release)
|
||||
const mcpServerVersion = "1"
|
||||
|
||||
// Protocol versions this server speaks; the newest is the default
|
||||
var mcpProtocolVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
|
||||
|
||||
type jsonrpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type jsonrpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type jsonrpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *jsonrpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
rpcParseError = -32700
|
||||
rpcInvalidRequest = -32600
|
||||
rpcMethodNotFound = -32601
|
||||
rpcInvalidParams = -32602
|
||||
)
|
||||
|
||||
// mcpTool is one callable tool: the scope an API key needs for it and the function doing the work.
|
||||
// run returns the structured result; an error becomes a tool error (isError) the agent can read.
|
||||
type mcpTool struct {
|
||||
Name string
|
||||
Description string
|
||||
Scope string
|
||||
Schema map[string]interface{}
|
||||
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
|
||||
}
|
||||
|
||||
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
|
||||
// read scope (its results are filtered to those scopes).
|
||||
func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
|
||||
if !isKey {
|
||||
return true
|
||||
}
|
||||
if tool.Scope == "" {
|
||||
return true
|
||||
}
|
||||
has := func(sc string) bool {
|
||||
for _, have := range scopes {
|
||||
if have == sc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if tool.Name == "search" {
|
||||
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
|
||||
}
|
||||
return has(tool.Scope)
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
|
||||
|
||||
var raw json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeRPC(w, nil, nil, &jsonrpcError{rpcParseError, "Ungültiges JSON"})
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Batch-Anfragen werden nicht unterstützt"})
|
||||
return
|
||||
}
|
||||
var req jsonrpcRequest
|
||||
if err := json.Unmarshal(raw, &req); err != nil || req.Method == "" {
|
||||
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Ungültige JSON-RPC-Anfrage"})
|
||||
return
|
||||
}
|
||||
// Notifications (no id) are acknowledged without a body
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
version := mcpProtocolVersions[0]
|
||||
for _, v := range mcpProtocolVersions {
|
||||
if v == p.ProtocolVersion {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
writeRPC(w, req.ID, map[string]interface{}{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
||||
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
|
||||
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
||||
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
||||
"lists and check_list_item to tick items. IDs come from the list tools; never guess them.",
|
||||
}, nil)
|
||||
case "ping":
|
||||
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
||||
case "tools/list":
|
||||
tools := []map[string]interface{}{}
|
||||
for _, t := range mcpTools {
|
||||
if !mcpToolAllowed(scopes, isKey, t) {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"inputSchema": t.Schema,
|
||||
})
|
||||
}
|
||||
writeRPC(w, req.ID, map[string]interface{}{"tools": tools}, nil)
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil || p.Name == "" {
|
||||
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Parameter 'name' fehlt"})
|
||||
return
|
||||
}
|
||||
tool, ok := mcpToolByName(p.Name)
|
||||
if !ok {
|
||||
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Unbekanntes Tool: " + p.Name})
|
||||
return
|
||||
}
|
||||
if !mcpToolAllowed(scopes, isKey, tool) {
|
||||
writeRPC(w, req.ID, toolError("Der API-Key hat keine Berechtigung für "+tool.Name+" (Scope "+tool.Scope+" fehlt)"), nil)
|
||||
return
|
||||
}
|
||||
if len(p.Arguments) == 0 {
|
||||
p.Arguments = json.RawMessage("{}")
|
||||
}
|
||||
result, err := tool.run(s, userID, p.Arguments)
|
||||
if err != nil {
|
||||
writeRPC(w, req.ID, toolError(err.Error()), nil)
|
||||
return
|
||||
}
|
||||
if tool.Name == "search" && isKey {
|
||||
result = filterSearchForScopes(result, scopes)
|
||||
}
|
||||
writeRPC(w, req.ID, toolResult(result), nil)
|
||||
default:
|
||||
writeRPC(w, req.ID, nil, &jsonrpcError{rpcMethodNotFound, "Methode nicht unterstützt: " + req.Method})
|
||||
}
|
||||
}
|
||||
|
||||
// handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to
|
||||
// end (DELETE).
|
||||
func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", "POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)")
|
||||
}
|
||||
|
||||
func writeRPC(w http.ResponseWriter, id json.RawMessage, result interface{}, rpcErr *jsonrpcError) {
|
||||
if len(id) == 0 {
|
||||
id = json.RawMessage("null")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, jsonrpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: rpcErr})
|
||||
}
|
||||
|
||||
// toolResult: the data as structuredContent plus the same JSON as text for clients without
|
||||
// structured output.
|
||||
func toolResult(data interface{}) map[string]interface{} {
|
||||
text, _ := json.MarshalIndent(data, "", " ")
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]string{{"type": "text", "text": string(text)}},
|
||||
"structuredContent": wrapStructured(data),
|
||||
}
|
||||
}
|
||||
|
||||
// structuredContent must be an object; arrays are wrapped
|
||||
func wrapStructured(data interface{}) interface{} {
|
||||
raw, _ := json.Marshal(data)
|
||||
if strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||
return map[string]interface{}{"items": data}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func toolError(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content": []map[string]string{{"type": "text", "text": msg}},
|
||||
"isError": true,
|
||||
}
|
||||
}
|
||||
|
||||
// --- tools ---
|
||||
|
||||
func mcpToolByName(name string) (mcpTool, bool) {
|
||||
for _, t := range mcpTools {
|
||||
if t.Name == name {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return mcpTool{}, false
|
||||
}
|
||||
|
||||
func schema(required []string, props map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
out["required"] = required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func prop(typ, desc string) map[string]interface{} {
|
||||
return map[string]interface{}{"type": typ, "description": desc}
|
||||
}
|
||||
|
||||
func decodeArgs(args json.RawMessage, into interface{}) error {
|
||||
if err := json.Unmarshal(args, into); err != nil {
|
||||
return errors.New("Ungültige Argumente: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskView is what an agent gets per task: the fields it can reason about, no internal bookkeeping.
|
||||
type taskView struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
|
||||
Recurrence string `json:"recurrence,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
func toTaskView(t *domain.Thought) taskView {
|
||||
return taskView{
|
||||
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
|
||||
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
|
||||
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type taskListView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
OpenTasks int `json:"openTasks"`
|
||||
}
|
||||
|
||||
type listView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ListType string `json:"listType"`
|
||||
Role string `json:"role"`
|
||||
OpenItems int `json:"openItems"`
|
||||
Items int `json:"items"`
|
||||
}
|
||||
|
||||
func toListView(l domain.SharedList) listView {
|
||||
open := 0
|
||||
for _, it := range l.Items {
|
||||
if !it.IsCompleted {
|
||||
open++
|
||||
}
|
||||
}
|
||||
role := l.Role
|
||||
if l.IsOwner {
|
||||
role = "owner"
|
||||
}
|
||||
return listView{ID: l.ID, Title: l.Title, Icon: l.Icon, ListType: l.ListType, Role: role, OpenItems: open, Items: len(l.Items)}
|
||||
}
|
||||
|
||||
var mcpTools = []mcpTool{
|
||||
{
|
||||
Name: "list_task_lists",
|
||||
Description: "All task lists the user can see, with permission (owner/editor/viewer) and the number of open tasks. Tasks without a list live in the personal inbox list (taskListId empty).",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema(nil, map[string]interface{}{}),
|
||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
||||
lists, err := s.store.GetTaskLists(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]taskListView, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
out = append(out, taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: l.Permission, OpenTasks: l.TasksCount})
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_tasks",
|
||||
Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema(nil, map[string]interface{}{
|
||||
"taskListId": prop("string", "Only tasks of this task list"),
|
||||
"status": map[string]interface{}{"type": "string", "enum": []string{"open", "done", "all"}, "description": "Default open"},
|
||||
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
|
||||
"limit": prop("integer", "Maximum number of tasks (default 100)"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
TaskListID string `json:"taskListId"`
|
||||
Status string `json:"status"`
|
||||
DueBefore string `json:"dueBefore"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks, err := s.store.GetTasks(userID, a.TaskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Limit <= 0 {
|
||||
a.Limit = 100
|
||||
}
|
||||
out := make([]taskView, 0)
|
||||
for i := range tasks {
|
||||
t := &tasks[i]
|
||||
switch a.Status {
|
||||
case "done":
|
||||
if !t.IsCompleted {
|
||||
continue
|
||||
}
|
||||
case "all":
|
||||
default:
|
||||
if t.IsCompleted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if a.DueBefore != "" && (t.DueDate == "" || t.DueDate[:min(10, len(t.DueDate))] > a.DueBefore) {
|
||||
continue
|
||||
}
|
||||
out = append(out, toTaskView(t))
|
||||
}
|
||||
// Due dates first (earliest on top), then the rest in list order
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if (out[i].DueDate == "") != (out[j].DueDate == "") {
|
||||
return out[i].DueDate != ""
|
||||
}
|
||||
return out[i].DueDate != "" && out[i].DueDate < out[j].DueDate
|
||||
})
|
||||
if len(out) > a.Limit {
|
||||
out = out[:a.Limit]
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_task",
|
||||
Description: "One task with all details.",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.store.GetThoughtByID(userID, a.ID)
|
||||
if err != nil || t == nil {
|
||||
return nil, errors.New("Aufgabe nicht gefunden")
|
||||
}
|
||||
return toTaskView(t), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_task",
|
||||
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"content"}, map[string]interface{}{
|
||||
"content": prop("string", "Task text; #hashtags become tags"),
|
||||
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
|
||||
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
||||
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
|
||||
"notes": prop("string", "Longer description"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Content string `json:"content"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"dueDate"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTaskView(t), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_task",
|
||||
Description: "Change fields of a task; only the given fields change. Empty dueDate removes the due date.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{
|
||||
"id": prop("string", "Task id"),
|
||||
"content": prop("string", "New text"),
|
||||
"isCompleted": prop("boolean", "true = done"),
|
||||
"dueDate": prop("string", "YYYY-MM-DD, YYYY-MM-DDTHH:MM or empty"),
|
||||
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
||||
"notes": prop("string", "Description"),
|
||||
"kanbanStatus": prop("string", "Column id of the task list's board (e.g. todo, in_progress, done)"),
|
||||
"taskListId": prop("string", "Move into another task list"),
|
||||
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
taskPatch
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
||||
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTaskView(t), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "complete_task",
|
||||
Description: "Check a task off (or reopen it with completed=false). Recurring tasks move to their next date.",
|
||||
Scope: storage.ScopeTasksWrite,
|
||||
Schema: schema([]string{"id"}, map[string]interface{}{
|
||||
"id": prop("string", "Task id"),
|
||||
"completed": prop("boolean", "Default true"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ID string `json:"id"`
|
||||
Completed *bool `json:"completed"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := a.Completed == nil || *a.Completed
|
||||
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTaskView(t), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_lists",
|
||||
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
||||
Scope: storage.ScopeListsRead,
|
||||
Schema: schema(nil, map[string]interface{}{}),
|
||||
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
|
||||
lists, err := s.store.GetAllListsForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]listView, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
out = append(out, toListView(l))
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_list",
|
||||
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
||||
Scope: storage.ScopeListsRead,
|
||||
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := s.store.GetListByID(userID, a.ListID)
|
||||
if err != nil || l == nil {
|
||||
return nil, errors.New("Liste nicht gefunden")
|
||||
}
|
||||
return map[string]interface{}{"list": toListView(*l), "items": l.Items}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "add_list_item",
|
||||
Description: "Add an item to a list the user may edit.",
|
||||
Scope: storage.ScopeListsWrite,
|
||||
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
|
||||
"listId": prop("string", "List id"),
|
||||
"content": prop("string", "Item text"),
|
||||
"category": prop("string", "Category/aisle, e.g. Obst"),
|
||||
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID, Content, Category, Quantity string
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check_list_item",
|
||||
Description: "Tick an item off a list (or untick it with completed=false).",
|
||||
Scope: storage.ScopeListsWrite,
|
||||
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
|
||||
"listId": prop("string", "List id"),
|
||||
"itemId": prop("string", "Item id from get_list"),
|
||||
"completed": prop("boolean", "Default true"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
ItemID string `json:"itemId"`
|
||||
Completed *bool `json:"completed"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_list_item",
|
||||
Description: "Change text, category or quantity of a list item; only the given fields change.",
|
||||
Scope: storage.ScopeListsWrite,
|
||||
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
|
||||
"listId": prop("string", "List id"),
|
||||
"itemId": prop("string", "Item id"),
|
||||
"content": prop("string", "New text"),
|
||||
"category": prop("string", "New category"),
|
||||
"quantity": prop("string", "New amount"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
ListID string `json:"listId"`
|
||||
ItemID string `json:"itemId"`
|
||||
Content *string `json:"content"`
|
||||
Category *string `json:"category"`
|
||||
Quantity *string `json:"quantity"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, a.ListID, a.ItemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.store.UpdateListItem(a.ItemID, a.Content, a.Category, a.Quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
||||
return item, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search",
|
||||
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
|
||||
Scope: storage.ScopeTasksRead,
|
||||
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.store.SearchGlobal(userID, a.Query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]taskView, 0, len(res.Thoughts))
|
||||
for i := range res.Thoughts {
|
||||
tasks = append(tasks, toTaskView(&res.Thoughts[i]))
|
||||
}
|
||||
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "capture_to_inbox",
|
||||
Description: "Drop a thought into the user's inbox for later triage (not a task yet).",
|
||||
Scope: storage.ScopeTriage,
|
||||
Schema: schema([]string{"content"}, map[string]interface{}{
|
||||
"content": prop("string", "The thought"),
|
||||
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
||||
"dueDate": prop("string", "YYYY-MM-DD, optional"),
|
||||
}),
|
||||
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
|
||||
var a struct {
|
||||
Content, Priority, DueDate string
|
||||
}
|
||||
if err := decodeArgs(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return nil, errors.New("'content' darf nicht leer sein")
|
||||
}
|
||||
if a.Priority == "" {
|
||||
a.Priority = "normal"
|
||||
}
|
||||
t, err := s.store.CreateThought(userID, a.Content, a.Priority, "agent", a.DueDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
|
||||
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// filterSearchForScopes drops the parts of a search result the key may not read.
|
||||
func filterSearchForScopes(result interface{}, scopes []string) interface{} {
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, sc := range scopes {
|
||||
has[sc] = true
|
||||
}
|
||||
if !has[storage.ScopeTasksRead] {
|
||||
m["tasks"] = []taskView{}
|
||||
}
|
||||
if !has[storage.ScopeListsRead] {
|
||||
m["listItems"] = []domain.ListItem{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// rpc posts one JSON-RPC request to the MCP endpoint and returns status and decoded body.
|
||||
func rpc(t *testing.T, router http.Handler, token, body string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
req.Header.Set("X-API-Key", token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]interface{}
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func toolNames(res map[string]interface{}) []string {
|
||||
names := []string{}
|
||||
result, _ := res["result"].(map[string]interface{})
|
||||
tools, _ := result["tools"].([]interface{})
|
||||
for _, tl := range tools {
|
||||
names = append(names, tl.(map[string]interface{})["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func callResult(t *testing.T, res map[string]interface{}) (map[string]interface{}, bool) {
|
||||
t.Helper()
|
||||
result, ok := res["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("no result in %v", res)
|
||||
}
|
||||
isErr, _ := result["isError"].(bool)
|
||||
return result, isErr
|
||||
}
|
||||
|
||||
// Agent scopes decide which tools a key sees and may call; webhook-only keys get nothing to read.
|
||||
func TestMCPToolsFollowScopes(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
|
||||
code, res := rpc(t, router, token, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("initialize: %d %v", code, res)
|
||||
}
|
||||
if v := res["result"].(map[string]interface{})["protocolVersion"]; v != "2025-03-26" {
|
||||
t.Fatalf("requested protocol version should be echoed, got %v", v)
|
||||
}
|
||||
// Notifications are acknowledged with 202 and no body
|
||||
code, _ = rpc(t, router, token, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("notification should answer 202, got %d", code)
|
||||
}
|
||||
|
||||
// A session sees every tool
|
||||
_, res = rpc(t, router, token, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
|
||||
if n := len(toolNames(res)); n != len(mcpTools) {
|
||||
t.Fatalf("session should see all %d tools, got %d", len(mcpTools), n)
|
||||
}
|
||||
|
||||
// Webhook-only key: only capture_to_inbox
|
||||
webhookKey, _ := store.CreateAPIKey(claims.UserID, "HA", nil)
|
||||
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
|
||||
if names := toolNames(res); len(names) != 1 || names[0] != "capture_to_inbox" {
|
||||
t.Fatalf("triage key should only see capture_to_inbox, got %v", names)
|
||||
}
|
||||
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
|
||||
if _, isErr := callResult(t, res); !isErr {
|
||||
t.Fatalf("triage key must not list tasks: %v", res)
|
||||
}
|
||||
|
||||
// Read-only task key: lists tasks, cannot complete them
|
||||
readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"})
|
||||
names := strings.Join(toolNames(func() map[string]interface{} { _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); return r }()), ",")
|
||||
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "list_lists") {
|
||||
t.Fatalf("tasks:read key sees the wrong tools: %s", names)
|
||||
}
|
||||
// search is offered with any read scope
|
||||
if !strings.Contains(names, "search") {
|
||||
t.Fatalf("search should be available with tasks:read: %s", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Full round trip with a tasks:write / lists:write key: create, list, complete; add and tick list items.
|
||||
func TestMCPTasksAndLists(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
|
||||
_, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_task","arguments":{"content":"Filter wechseln #haus","priority":"high","dueDate":"2026-11-15"}}}`)
|
||||
result, isErr := callResult(t, res)
|
||||
if isErr {
|
||||
t.Fatalf("create_task failed: %v", result)
|
||||
}
|
||||
created := result["structuredContent"].(map[string]interface{})
|
||||
taskID, _ := created["id"].(string)
|
||||
if taskID == "" || created["dueDate"] != "2026-11-15" {
|
||||
t.Fatalf("unexpected task: %v", created)
|
||||
}
|
||||
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
||||
if th == nil || th.Source != "agent" || th.Status != "converted" && th.Status != "kept" {
|
||||
t.Fatalf("task not stored as task: %+v", th)
|
||||
}
|
||||
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tasks","arguments":{"dueBefore":"2026-11-15"}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
items := result["structuredContent"].(map[string]interface{})["items"].([]interface{})
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it.(map[string]interface{})["id"] == taskID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("created task missing from list_tasks: %v", items)
|
||||
}
|
||||
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"complete_task","arguments":{"id":"`+taskID+`"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
|
||||
t.Fatalf("complete_task: %v", result)
|
||||
}
|
||||
// Done tasks drop out of the default listing
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
for _, it := range result["structuredContent"].(map[string]interface{})["items"].([]interface{}) {
|
||||
if it.(map[string]interface{})["id"] == taskID {
|
||||
t.Fatalf("completed task should not be listed as open")
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
list, _ := store.CreateList(claims.UserID, "Einkauf", "🛒", "shopping")
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+list.ID+`","content":"Hafermilch","quantity":"2"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr {
|
||||
t.Fatalf("add_list_item: %v", result)
|
||||
}
|
||||
itemID := result["structuredContent"].(map[string]interface{})["id"].(string)
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"check_list_item","arguments":{"listId":"`+list.ID+`","itemId":"`+itemID+`"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
|
||||
t.Fatalf("check_list_item: %v", result)
|
||||
}
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_list","arguments":{"listId":"`+list.ID+`"}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
lv := result["structuredContent"].(map[string]interface{})["list"].(map[string]interface{})
|
||||
if lv["openItems"].(float64) != 0 || lv["items"].(float64) != 1 {
|
||||
t.Fatalf("get_list counts wrong: %v", lv)
|
||||
}
|
||||
|
||||
// Lists of other people stay invisible
|
||||
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+foreign.ID+`","content":"Spam"}}}`)
|
||||
if _, isErr = callResult(t, res); !isErr {
|
||||
t.Fatalf("key must not write into foreign lists")
|
||||
}
|
||||
|
||||
// Unknown method and batch requests are JSON-RPC errors
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":9,"method":"resources/list"}`)
|
||||
if res["error"] == nil {
|
||||
t.Fatalf("unknown method should be a JSON-RPC error: %v", res)
|
||||
}
|
||||
_, res = rpc(t, router, key.RawKey, `[{"jsonrpc":"2.0","id":10,"method":"ping"}]`)
|
||||
if res["error"] == nil {
|
||||
t.Fatalf("batches should be refused: %v", res)
|
||||
}
|
||||
// Without a key there is no access at all
|
||||
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(`{"jsonrpc":"2.0","id":11,"method":"ping"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous MCP call should be 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func (s *Server) flushNotifications(now time.Time) {
|
||||
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||
out.add(set.Channels(q.Kind), item)
|
||||
}
|
||||
if err := s.deliver(uid, "Neuigkeiten", out, false); err != nil {
|
||||
if err := s.deliver(uid, "Neuigkeiten", out, false, now); err != nil {
|
||||
if maxAttempts+1 >= notifyMaxAttempts {
|
||||
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
||||
_ = s.store.DeleteQueuedNotifications(ids)
|
||||
@@ -210,7 +210,8 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||
}
|
||||
|
||||
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
|
||||
func (s *Server) deliver(userID, heading string, out channelItems, summary bool) error {
|
||||
// now is the time the run is for (greeting "Guten Morgen" follows the summary time, not the clock).
|
||||
func (s *Server) deliver(userID, heading string, out channelItems, summary bool, now time.Time) error {
|
||||
emailItems, discordItems := out.email, out.discord
|
||||
u, err := s.store.GetUserByID(userID)
|
||||
if err != nil || u == nil || !u.IsActive {
|
||||
@@ -226,9 +227,9 @@ func (s *Server) deliver(userID, heading string, out channelItems, summary bool)
|
||||
if len(emailItems) == 1 {
|
||||
subject = "MindSift: " + emailItems[0].text
|
||||
}
|
||||
c := s.digestMail(u.DisplayName, emailItems)
|
||||
c := s.digestMail(u.DisplayName, emailItems, now)
|
||||
if summary {
|
||||
c = s.summaryMail(u.DisplayName, emailItems)
|
||||
c = s.summaryMail(u.DisplayName, emailItems, now)
|
||||
}
|
||||
text, htmlBody := renderMail(c)
|
||||
if err := s.sendNotifyEmail(to, subject, text, htmlBody); err != nil {
|
||||
@@ -368,7 +369,7 @@ func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyIte
|
||||
}
|
||||
|
||||
// digestMail: what happened since the last mail (shares, completed shared tasks, watched tasks).
|
||||
func (s *Server) digestMail(name string, items []notifyItem) mailContent {
|
||||
func (s *Server) digestMail(name string, items []notifyItem, now time.Time) mailContent {
|
||||
entries := make([]mailItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
entries = append(entries, mailItem{Title: it.text})
|
||||
@@ -379,7 +380,7 @@ func (s *Server) digestMail(name string, items []notifyItem) mailContent {
|
||||
}
|
||||
c := mailContent{
|
||||
Preheader: items[0].text,
|
||||
Greeting: mailGreeting(time.Now(), name),
|
||||
Greeting: mailGreeting(now, name),
|
||||
Intro: intro,
|
||||
Sections: []mailSection{{Items: entries}},
|
||||
Footer: mailFooterAlarms,
|
||||
@@ -391,7 +392,7 @@ func (s *Server) digestMail(name string, items []notifyItem) mailContent {
|
||||
}
|
||||
|
||||
// summaryMail: the morning overview, grouped into overdue / today / tomorrow.
|
||||
func (s *Server) summaryMail(name string, items []notifyItem) mailContent {
|
||||
func (s *Server) summaryMail(name string, items []notifyItem, now time.Time) mailContent {
|
||||
groups := []struct{ key, title, tone string }{
|
||||
{"overdue", "Überfällig", "danger"},
|
||||
{"today", "Heute", ""},
|
||||
@@ -437,7 +438,7 @@ func (s *Server) summaryMail(name string, items []notifyItem) mailContent {
|
||||
}
|
||||
c := mailContent{
|
||||
Preheader: "Deine Aufgaben: " + strings.Join(parts, ", "),
|
||||
Greeting: mailGreeting(time.Now(), name),
|
||||
Greeting: mailGreeting(now, name),
|
||||
Intro: "Hier ist dein Überblick für heute – " + strings.Join(parts, ", ") + ".",
|
||||
Sections: sections,
|
||||
Outro: outro,
|
||||
@@ -471,7 +472,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
||||
}
|
||||
var out channelItems
|
||||
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, items...)
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", out, true); err != nil {
|
||||
if err := s.deliver(uid, "Deine Aufgaben für heute", out, true, now); err != nil {
|
||||
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,8 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
Device string `json:"device"`
|
||||
// The subscription this browser had before (e.g. before a reinstall): it is dead now
|
||||
Replaces string `json:"replaces"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
@@ -165,10 +167,14 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if len(device) > 120 {
|
||||
device = device[:120]
|
||||
}
|
||||
if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
if err := s.store.SavePushSubscription(userID, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
if req.Replaces != "" && req.Replaces != req.Endpoint {
|
||||
_ = s.store.DeletePushSubscription(userID, req.Replaces) // only ever one of the own devices
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -77,3 +77,24 @@ func TestPushDigestText(t *testing.T) {
|
||||
t.Fatalf("many: %+v", many)
|
||||
}
|
||||
}
|
||||
|
||||
// A reinstalled app registers a new subscription and names the old one: it is dropped, not counted twice
|
||||
func TestPushResubscribeReplacesOldDevice(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
me, token := newTestUser(t, store, "reinstall@example.com")
|
||||
sub := func(endpoint, replaces string) int {
|
||||
body := `{"endpoint":"` + endpoint + `","keys":{"p256dh":"k","auth":"a"},"device":"Chrome auf Android","replaces":"` + replaces + `"}`
|
||||
return sessionCall(router, http.MethodPost, "/api/v1/push/subscribe", token, body, "").Code
|
||||
}
|
||||
if code := sub("https://fcm.googleapis.com/fcm/send/alt", ""); code != http.StatusOK {
|
||||
t.Fatalf("subscribe: %d", code)
|
||||
}
|
||||
if code := sub("https://fcm.googleapis.com/fcm/send/neu", "https://fcm.googleapis.com/fcm/send/alt"); code != http.StatusOK {
|
||||
t.Fatalf("resubscribe: %d", code)
|
||||
}
|
||||
subs, _ := store.PushSubscriptions(me.ID)
|
||||
if len(subs) != 1 || subs[0].Endpoint != "https://fcm.googleapis.com/fcm/send/neu" {
|
||||
t.Fatalf("old installation must be replaced, got %+v", subs)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
inv, err := store.CreateUserInvite("", "", "member", "", 7)
|
||||
inv, err := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
||||
func TestInviteReleasedWhenAccountFails(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
|
||||
body := func(email string) string {
|
||||
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Registration without an invite (mode and e-mail domains), approval of waiting accounts, and sending
|
||||
// invite links by mail. See Doku/plans/registrierung-und-login.md.
|
||||
|
||||
type registrationPolicy struct {
|
||||
Mode string
|
||||
Domains []string
|
||||
ViaEnv bool
|
||||
// VerifyEmail: self-registrations confirm their address by link first (only when mails with links
|
||||
// can go out, see emailLinksAvailable)
|
||||
VerifyEmail bool
|
||||
}
|
||||
|
||||
// registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin
|
||||
// settings, so an operator can lock it down for good.
|
||||
func (s *Server) registrationPolicy() registrationPolicy {
|
||||
p := registrationPolicy{Mode: storage.RegistrationClosed}
|
||||
if sys, _ := s.store.GetSystemSettings(); sys != nil {
|
||||
p.Mode = sys.RegistrationMode
|
||||
p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains)
|
||||
p.VerifyEmail = sys.RegistrationVerifyEmail
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
p.VerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||
}
|
||||
p.VerifyEmail = p.VerifyEmail && s.emailLinksAvailable()
|
||||
if s.cfg.RegistrationMode != "" {
|
||||
p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true
|
||||
}
|
||||
if s.cfg.RegistrationDomains != "" {
|
||||
p.Domains, p.ViaEnv = storage.ParseEmailDomains(s.cfg.RegistrationDomains), true
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// registrationOutcome decides for a sign-up without invite: allowed at all, and active at once or
|
||||
// waiting for approval. The error is shown to the person.
|
||||
func (p registrationPolicy) registrationOutcome(email string) (pending bool, err error) {
|
||||
inDomains := storage.EmailInDomains(email, p.Domains)
|
||||
switch p.Mode {
|
||||
case storage.RegistrationOpen:
|
||||
return false, nil
|
||||
case storage.RegistrationApproval:
|
||||
return true, nil
|
||||
case storage.RegistrationDomain:
|
||||
if !inDomains {
|
||||
return false, errors.New("Registrierung nur mit E-Mail-Adressen von " + domainList(p.Domains))
|
||||
}
|
||||
return false, nil
|
||||
case storage.RegistrationDomainApproval:
|
||||
return !inDomains, nil
|
||||
}
|
||||
return false, errors.New("Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
||||
}
|
||||
|
||||
func domainList(domains []string) string {
|
||||
if len(domains) == 0 {
|
||||
return "freigegebenen Domains"
|
||||
}
|
||||
out := make([]string, len(domains))
|
||||
for i, d := range domains {
|
||||
out[i] = "@" + strings.TrimPrefix(d, "*.")
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// emailLinksAvailable: mails with links to people who are not logged in can go out – a mail server
|
||||
// and BASE_URL (links to strangers are never built from the request's Host header).
|
||||
func (s *Server) emailLinksAvailable() bool {
|
||||
return s.passwordResetAvailable()
|
||||
}
|
||||
|
||||
// sendVerificationMail mails the confirmation link of a new account.
|
||||
func (s *Server) sendVerificationMail(user *domain.User) {
|
||||
raw, err := s.store.CreateEmailVerification(user.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrResetTooSoon) {
|
||||
slog.Warn("Bestätigungslink konnte nicht erzeugt werden", "user_id", user.ID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?verifyToken=" + url.QueryEscape(raw)
|
||||
err = s.sendMailContent(user.Email, "MindSift: Bitte bestätige deine E-Mail-Adresse", mailContent{
|
||||
Preheader: "Ein Klick, dann ist dein Konto bereit",
|
||||
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||
Intro: "Du hast dich bei MindSift registriert. Bitte bestätige noch deine E-Mail-Adresse.",
|
||||
Button: &mailButton{Label: "E-Mail-Adresse bestätigen", URL: link},
|
||||
Note: "Der Link gilt 48 Stunden. Hast du dich nicht registriert, ignoriere diese E-Mail – das Konto wird dann automatisch gelöscht.\n" +
|
||||
"Falls der Knopf nicht geht: " + link,
|
||||
Footer: mailFooterAccount,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("Bestätigungs-E-Mail nicht verschickt", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVerifyEmail: POST /auth/verify-email {"token"} – the link from the mail. Without approval the
|
||||
// account is active now, otherwise it goes on to the admins.
|
||||
func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ConfirmEmailVerification(strings.TrimSpace(req.Token))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
|
||||
return
|
||||
}
|
||||
if s.store.IsPendingRegistration(userID) {
|
||||
go s.notifyAdminsOfRegistration(user)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"verified": true, "pending": true,
|
||||
"message": "Deine E-Mail-Adresse ist bestätigt. Ein Administrator schaltet dein Konto noch frei – du bekommst dann eine E-Mail.",
|
||||
})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if _, err := s.store.UpdateUser(userID, nil, nil, nil, &active, nil, nil); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht freigeschaltet werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"verified": true, "pending": false,
|
||||
"message": "Deine E-Mail-Adresse ist bestätigt. Du kannst dich jetzt anmelden.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleResendVerification: POST /auth/resend-verification {"email"} – always the same answer, the
|
||||
// mail goes out in the background (at most every 2 minutes per account).
|
||||
func (s *Server) handleResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if s.emailLinksAvailable() {
|
||||
go func() {
|
||||
if user, err := s.store.GetUserByEmail(email); err == nil && user != nil && s.store.IsEmailUnverified(user.ID) {
|
||||
s.sendVerificationMail(user)
|
||||
}
|
||||
}()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the
|
||||
// admin is using right now (only for mails an admin triggers, never for anonymous requests).
|
||||
func (s *Server) appLink(r *http.Request) string {
|
||||
if s.cfg.BaseURLSet {
|
||||
return strings.TrimRight(s.cfg.BaseURL, "/") + "/"
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
return scheme + "://" + host + "/"
|
||||
}
|
||||
|
||||
// notifyAdminsOfRegistration mails every active admin that someone waits (only with SMTP).
|
||||
func (s *Server) notifyAdminsOfRegistration(user *domain.User) {
|
||||
if _, err := s.smtpSettings(); err != nil {
|
||||
return
|
||||
}
|
||||
users, err := s.store.GetAllUsers()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, a := range users {
|
||||
if a.Role != "admin" || !a.IsActive || a.Email == "" {
|
||||
continue
|
||||
}
|
||||
c := mailContent{
|
||||
Preheader: user.DisplayName + " wartet auf Freigabe",
|
||||
Greeting: mailGreeting(time.Now(), a.DisplayName),
|
||||
Intro: "Jemand hat sich bei MindSift registriert und wartet auf deine Freigabe:",
|
||||
Sections: []mailSection{{Items: []mailItem{{Title: user.DisplayName, Meta: user.Email}}}},
|
||||
Outro: "Freigeben oder ablehnen kannst du in den Administrator-Einstellungen unter „Benutzer & Einladungen“.",
|
||||
Footer: "Du bekommst diese E-Mail als Administrator dieser MindSift-Instanz.",
|
||||
}
|
||||
if link := s.notifyLink(); link != "" {
|
||||
c.Button = &mailButton{Label: "Benutzerverwaltung öffnen", URL: link}
|
||||
}
|
||||
if err := s.sendMailContent(a.Email, "MindSift: Neue Registrierung wartet auf Freigabe", c); err != nil {
|
||||
slog.Warn("Hinweis auf neue Registrierung nicht verschickt", "admin", a.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminApproveUser: POST /admin/users/{id}/approve – a waiting account becomes active.
|
||||
func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
ok, err := s.store.ApprovePendingRegistration(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "Dieses Konto wartet nicht auf Freigabe")
|
||||
return
|
||||
}
|
||||
user.IsActive = true
|
||||
s.store.ClearEmailVerification(userID) // the admin vouches for the account
|
||||
link := s.appLink(r)
|
||||
go func() {
|
||||
_ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{
|
||||
Preheader: "Du kannst dich jetzt anmelden",
|
||||
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||
Intro: "Dein MindSift-Konto ist freigeschaltet. Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden.",
|
||||
Button: &mailButton{Label: "Zu MindSift", URL: link},
|
||||
Footer: mailFooterAccount,
|
||||
})
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// handleAdminSendInvite: POST /admin/invites/{id}/send {"email"} – mails the invite link. A single-use
|
||||
// invite is bound to that address on the way.
|
||||
func (s *Server) handleAdminSendInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !strings.Contains(req.Email, "@") {
|
||||
writeError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if _, err := s.smtpSettings(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Für Einladungen per E-Mail muss ein Mail-Server eingerichtet sein")
|
||||
return
|
||||
}
|
||||
inv, err := s.store.GetUserInviteByID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
if inv.MaxUses == 1 {
|
||||
if inv.Email != "" && !strings.EqualFold(inv.Email, email) {
|
||||
writeError(w, http.StatusConflict, "Diese Einladung ist an "+inv.Email+" gebunden")
|
||||
return
|
||||
}
|
||||
if inv.Email == "" {
|
||||
if err := s.store.BindUserInviteEmail(inv.ID, email); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
inv.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
inviter := "Jemand"
|
||||
if me, _ := s.store.GetUserByID(auth.GetUserID(r.Context())); me != nil && me.DisplayName != "" {
|
||||
inviter = me.DisplayName
|
||||
}
|
||||
link := s.appLink(r) + "?invite=" + url.QueryEscape(inv.Code)
|
||||
until := inv.ExpiresAt
|
||||
if t, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil {
|
||||
until = t.Local().Format("02.01.2006")
|
||||
}
|
||||
err = s.sendMailContent(email, inviter+" lädt dich zu MindSift ein", mailContent{
|
||||
Preheader: inviter + " lädt dich zu MindSift ein",
|
||||
Greeting: "Hallo",
|
||||
Intro: inviter + " lädt dich zu MindSift ein – Gedanken abladen, Aufgaben, Notizen und gemeinsame Listen an einem Ort.",
|
||||
Button: &mailButton{Label: "Konto anlegen", URL: link},
|
||||
Note: "Die Einladung gilt bis " + until + ". Falls der Knopf nicht geht: " + link + "\nEinladungscode: " + inv.Code,
|
||||
Footer: "Du bekommst diese E-Mail, weil dich jemand zu MindSift eingeladen hat.",
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inv)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
func setRegistration(t *testing.T, store *storage.Store, mode, domains string) {
|
||||
t.Helper()
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationMode, sys.RegistrationDomains = mode, domains
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func register(srv *Server, email, invite string) (int, map[string]interface{}) {
|
||||
body := `{"email":"` + email + `","password":"password123","displayName":"` + strings.Split(email, "@")[0] + `","inviteCode":"` + invite + `"}`
|
||||
rec := sessionCall(srv.Router(), "POST", "/api/v1/auth/register", "", body, "")
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func TestRegistrationModes(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
|
||||
setRegistration(t, store, storage.RegistrationClosed, "")
|
||||
if code, _ := register(srv, "a@example.com", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("closed: want 403, got %d", code)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationOpen, "")
|
||||
if code, out := register(srv, "b@example.com", ""); code != http.StatusCreated || out["token"] == nil {
|
||||
t.Fatalf("open: want 201 with token, got %d %v", code, out)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationDomain, "firma.de, @familie.de")
|
||||
if code, _ := register(srv, "c@fremd.de", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("domain: foreign address must be refused, got %d", code)
|
||||
}
|
||||
if code, _ := register(srv, "c@Familie.DE", ""); code != http.StatusCreated {
|
||||
t.Fatalf("domain: listed domain (any case) must be active at once, got %d", code)
|
||||
}
|
||||
|
||||
setRegistration(t, store, storage.RegistrationDomainApproval, "firma.de")
|
||||
if code, _ := register(srv, "d@firma.de", ""); code != http.StatusCreated {
|
||||
t.Fatalf("domain+approval: listed domain active at once, got %d", code)
|
||||
}
|
||||
if code, out := register(srv, "e@fremd.de", ""); code != http.StatusAccepted || out["pending"] != true || out["token"] != nil {
|
||||
t.Fatalf("domain+approval: others wait without session, got %d %v", code, out)
|
||||
}
|
||||
|
||||
// An invite skips mode and domain check
|
||||
setRegistration(t, store, storage.RegistrationClosed, "")
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
if code, _ := register(srv, "f@fremd.de", inv.Code); code != http.StatusCreated {
|
||||
t.Fatalf("invite must work in closed mode, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalFlow(t *testing.T) {
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationApproval, "")
|
||||
|
||||
if code, _ := register(srv, "neu@example.com", ""); code != http.StatusAccepted {
|
||||
t.Fatalf("approval: want 202, got %d", code)
|
||||
}
|
||||
login := func() *http.Response {
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "")
|
||||
return rec.Result()
|
||||
}
|
||||
res := login()
|
||||
var errBody map[string]string
|
||||
_ = json.NewDecoder(res.Body).Decode(&errBody)
|
||||
if res.StatusCode != http.StatusForbidden || !strings.Contains(errBody["error"], "Freigabe") {
|
||||
t.Fatalf("login before approval: want 403 mentioning Freigabe, got %d %v", res.StatusCode, errBody)
|
||||
}
|
||||
|
||||
// The admin sees the account as waiting and approves it
|
||||
var users []domain.User
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/users", adminToken, "", "").Body.Bytes(), &users)
|
||||
var waiting *domain.User
|
||||
for i := range users {
|
||||
if users[i].Email == "neu@example.com" {
|
||||
waiting = &users[i]
|
||||
}
|
||||
}
|
||||
if waiting == nil || !waiting.PendingApproval || waiting.IsActive {
|
||||
t.Fatalf("admin list must show the waiting account, got %+v", waiting)
|
||||
}
|
||||
_, memberToken := newTestUser(t, store, "member@example.com")
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", memberToken, "", ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("members must not approve, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if res := login(); res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login after approval: want 200, got %d", res.StatusCode)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("approving twice: want 409, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteLinkForSeveralPeople(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
if _, err := store.CreateUserInvite("", "x@example.com", "member", "", 7, 3); err == nil {
|
||||
t.Fatal("a link for several people must not be bound to one address")
|
||||
}
|
||||
inv, err := store.CreateUserInvite("", "", "member", "", 7, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, email := range []string{"eins@example.com", "zwei@example.com"} {
|
||||
if code, out := register(srv, email, inv.Code); code != http.StatusCreated {
|
||||
t.Fatalf("%s: want 201, got %d %v", email, code, out)
|
||||
}
|
||||
}
|
||||
if code, out := register(srv, "drei@example.com", inv.Code); code != http.StatusForbidden || !strings.Contains(out["error"].(string), "ausgeschöpft") {
|
||||
t.Fatalf("third use: want 403 'ausgeschöpft', got %d %v", code, out)
|
||||
}
|
||||
invites, _ := store.GetUserInvites()
|
||||
if len(invites) != 1 || invites[0].UseCount != 2 || invites[0].MaxUses != 2 {
|
||||
t.Fatalf("use count wrong: %+v", invites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationFixedByEnvironment(t *testing.T) {
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
srv.cfg.RegistrationMode = storage.RegistrationClosed
|
||||
|
||||
// The admin form shows the fixed value as locked and cannot change it
|
||||
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", adminToken, `{"registrationMode":"open","allowRegistration":true,"instanceName":"MindSift"}`, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var sys domain.SystemSettings
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/settings", adminToken, "", "").Body.Bytes(), &sys)
|
||||
if sys.RegistrationMode != storage.RegistrationClosed || !sys.RegistrationConfiguredViaEnv {
|
||||
t.Fatalf("environment must win and be marked, got %+v", sys)
|
||||
}
|
||||
if code, _ := register(srv, "x@example.com", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("registration must stay closed, got %d", code)
|
||||
}
|
||||
if stored, _ := store.GetSystemSettings(); stored.RegistrationMode == storage.RegistrationOpen {
|
||||
t.Fatalf("the locked value must not be overwritten in the database")
|
||||
}
|
||||
}
|
||||
|
||||
// verificationServer: mails can go out (SMTP + BASE_URL); returns the captured mails
|
||||
func verificationServer(t *testing.T) (*Server, *storage.Store, string, func() []sentNote) {
|
||||
t.Helper()
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
srv.cfg.BaseURL, srv.cfg.BaseURLSet = "https://mindsift.example", true
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.SMTPHost, sys.RegistrationVerifyEmail = "smtp.example.com", true
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var sent []sentNote
|
||||
srv.notifyEmailHook = func(to, subject, text, html string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
|
||||
return nil
|
||||
}
|
||||
mails := func() []sentNote {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]sentNote(nil), sent...)
|
||||
}
|
||||
return srv, store, adminToken, mails
|
||||
}
|
||||
|
||||
// waitMail waits for a mail to `to` (mails go out in the background)
|
||||
func waitMail(t *testing.T, mails func() []sentNote, to string) sentNote {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
for _, m := range mails() {
|
||||
if m.to == to {
|
||||
return m
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no mail to %s, got %+v", to, mails())
|
||||
return sentNote{}
|
||||
}
|
||||
|
||||
func TestEmailVerification(t *testing.T) {
|
||||
srv, store, _, mails := verificationServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationOpen, "")
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationVerifyEmail = true
|
||||
_ = store.UpdateSystemSettings(sys)
|
||||
|
||||
code, out := register(srv, "neu@example.com", "")
|
||||
if code != http.StatusAccepted || out["verify"] != true || out["token"] != nil {
|
||||
t.Fatalf("register: want 202 with verify and no session, got %d %v", code, out)
|
||||
}
|
||||
mail := waitMail(t, mails, "neu@example.com")
|
||||
m := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)
|
||||
if m == nil || !strings.HasPrefix(mail.text[strings.Index(mail.text, "https://"):], "https://mindsift.example/") {
|
||||
t.Fatalf("mail must carry a link built from BASE_URL: %s", mail.text)
|
||||
}
|
||||
login := func() int {
|
||||
return sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "").Code
|
||||
}
|
||||
if c := login(); c != http.StatusForbidden {
|
||||
t.Fatalf("login before confirming: want 403, got %d", c)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+strings.Repeat("0", 64)+`"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("wrong token: want 400, got %d", rec.Code)
|
||||
}
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":false`) {
|
||||
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c := login(); c != http.StatusOK {
|
||||
t.Fatalf("login after confirming: want 200, got %d", c)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a link works only once, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Invites need no confirmation
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
if code, out := register(srv, "eingeladen@example.com", inv.Code); code != http.StatusCreated || out["token"] == nil {
|
||||
t.Fatalf("invite: want 201 with session, got %d %v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailVerificationBeforeApproval(t *testing.T) {
|
||||
srv, store, _, mails := verificationServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationApproval, "")
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationVerifyEmail = true
|
||||
_ = store.UpdateSystemSettings(sys)
|
||||
admin, err := store.GetFirstUser()
|
||||
if err != nil || admin == nil || admin.Role != "admin" {
|
||||
t.Fatalf("test server needs its admin, got %+v %v", admin, err)
|
||||
}
|
||||
adminEmail := admin.Email
|
||||
|
||||
if code, _ := register(srv, "wartet@example.com", ""); code != http.StatusAccepted {
|
||||
t.Fatalf("register: want 202, got %d", code)
|
||||
}
|
||||
mail := waitMail(t, mails, "wartet@example.com")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
for _, m := range mails() {
|
||||
if m.to != "wartet@example.com" {
|
||||
t.Fatalf("admins must not hear about it before the address is confirmed, got mail to %s", m.to)
|
||||
}
|
||||
}
|
||||
token := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)[1]
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+token+`"}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":true`) {
|
||||
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if m := waitMail(t, mails, adminEmail); !strings.Contains(m.subject, "Freigabe") {
|
||||
t.Fatalf("admin mail after confirming expected, got %q", m.subject)
|
||||
}
|
||||
if c := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"wartet@example.com","password":"password123"}`, "").Code; c != http.StatusForbidden {
|
||||
t.Fatalf("still waiting for approval: want 403, got %d", c)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func restructureStatus(err error) int {
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, storage.ErrBlockNotFound), errors.Is(err, storage.ErrDocNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash):
|
||||
case errors.Is(err, storage.ErrMergeSelf), errors.Is(err, storage.ErrDocInTrash), errors.Is(err, storage.ErrAlreadyNote):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, storage.ErrMergeNotOwner):
|
||||
return http.StatusForbidden
|
||||
|
||||
@@ -84,6 +84,23 @@ func TestExtractBranchIntoOwnNote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsBulletThatIsOnlyANoteLink(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
src, _ := store.CreateDocument(claims.UserID, "", "Projekt", "", "", []domain.DocumentBlock{
|
||||
{ID: "blk_l", Type: "bullet", Content: "[[Konzept: Garten]] #draußen", Level: 0, Position: 1},
|
||||
{ID: "blk_m", Type: "bullet", Content: "Siehe [[Konzept: Garten]] für Details", Level: 0, Position: 2},
|
||||
}, nil, false)
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_l"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("link-only bullet: want 400, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// A link inside other text is ordinary content
|
||||
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_m"}`, ""); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("bullet with text and link: want 201, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
@@ -111,7 +111,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key", "Mcp-Session-Id", "MCP-Protocol-Version"},
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
@@ -122,6 +122,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Get("/api/v1/legal/{type}", s.handleLegalText)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/verify-email", s.handleVerifyEmail)
|
||||
r.Post("/api/v1/auth/resend-verification", s.handleResendVerification)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
@@ -277,6 +279,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
// MCP endpoint for AI agents (API key with agent scopes or a session); no server stream, so
|
||||
// GET and DELETE answer 405 instead of the SPA fallback
|
||||
r.Post(mcpPath, s.handleMCP)
|
||||
r.Get(mcpPath, handleMCPNoStream)
|
||||
r.Delete(mcpPath, handleMCPNoStream)
|
||||
|
||||
// API Keys Management
|
||||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
@@ -290,10 +298,12 @@ func (s *Server) Router() http.Handler {
|
||||
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.Post("/api/v1/admin/users/{id}/approve", s.notInDemo(s.handleAdminApproveUser))
|
||||
|
||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
||||
r.Post("/api/v1/admin/invites/{id}/send", s.notInDemo(s.handleAdminSendInvite))
|
||||
})
|
||||
|
||||
// Server-Admin Strictly Protected Routes
|
||||
@@ -505,11 +515,11 @@ func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string)
|
||||
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.
|
||||
// apiKeyAllowed: an API key delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
||||
// authenticates on its own) or talks to the MCP endpoint for AI agents; what it may do there is decided
|
||||
// by its scopes (requireDeliveryScope, mcpToolAllowed). Everything else needs a login.
|
||||
func apiKeyAllowed(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
|
||||
return r.Method == http.MethodPost && (r.URL.Path == "/api/v1/inbox" || r.URL.Path == mcpPath)
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
@@ -534,7 +544,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
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)")
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest) oder den MCP-Endpunkt nutzen (POST /api/v1/mcp)")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
|
||||
|
||||
@@ -20,9 +20,17 @@ type Config struct {
|
||||
// from it, never from the request's Host header.
|
||||
BaseURLSet bool
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// RegistrationMode / RegistrationDomains: REGISTRATION_MODE (closed | approval | domain |
|
||||
// domain+approval | open) and REGISTRATION_DOMAINS. When set they win over the admin settings,
|
||||
// which then cannot change them. ALLOW_REGISTRATION=true counts as REGISTRATION_MODE=open;
|
||||
// ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings.
|
||||
RegistrationMode string
|
||||
RegistrationDomains string
|
||||
// RegistrationVerifyEmail: REGISTRATION_VERIFY_EMAIL ("true"/"false", "" = admin settings decide)
|
||||
RegistrationVerifyEmail string
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
|
||||
DemoResetMinutes int
|
||||
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
|
||||
@@ -227,27 +235,30 @@ func Load() *Config {
|
||||
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Port: port,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
RegistrationMode: registrationMode(allowReg),
|
||||
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
|
||||
RegistrationVerifyEmail: boolEnvOrEmpty("REGISTRATION_VERIFY_EMAIL"),
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Speech: SpeechConfig{
|
||||
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
|
||||
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
|
||||
@@ -326,3 +337,29 @@ func demoResetMinutes() int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// registrationMode reads REGISTRATION_MODE; unknown values are ignored ("" = admin settings decide).
|
||||
func registrationMode(allowReg bool) string {
|
||||
switch m := strings.ToLower(strings.TrimSpace(os.Getenv("REGISTRATION_MODE"))); m {
|
||||
case "closed", "approval", "domain", "domain+approval", "open":
|
||||
return m
|
||||
case "":
|
||||
default:
|
||||
slog.Warn("REGISTRATION_MODE unbekannt, wird ignoriert", "value", m)
|
||||
}
|
||||
if allowReg {
|
||||
return "open"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// boolEnvOrEmpty: "true" or "false" when the variable is set to a boolean, "" when unset or unknown.
|
||||
func boolEnvOrEmpty(key string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "true", "1", "yes", "on":
|
||||
return "true"
|
||||
case "false", "0", "no", "off":
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -135,6 +135,10 @@ type User struct {
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// Registered while registration needs approval and not approved yet (admin user list only)
|
||||
PendingApproval bool `json:"pendingApproval,omitempty"`
|
||||
// Registered and the e-mail address is not confirmed yet (admin user list only)
|
||||
EmailUnverified bool `json:"emailUnverified,omitempty"`
|
||||
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||
}
|
||||
@@ -216,7 +220,10 @@ type UserInvite struct {
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// A link for several people: how often it may be used and how often it was
|
||||
MaxUses int `json:"maxUses"`
|
||||
UseCount int `json:"useCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -350,12 +357,25 @@ type TeamMember struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
// AllowRegistration mirrors RegistrationMode == "open" (older clients and the setup wizard)
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
// Who may register without an invite: closed | approval | domain | domain+approval | open
|
||||
RegistrationMode string `json:"registrationMode"`
|
||||
// E-mail domains for the domain modes, comma separated ("firma.de, familie.de")
|
||||
RegistrationDomains string `json:"registrationDomains"`
|
||||
// Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here
|
||||
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
|
||||
// Self-registrations confirm their e-mail address by link first (needs SMTP and BASE_URL)
|
||||
RegistrationVerifyEmail bool `json:"registrationVerifyEmail"`
|
||||
// Set by REGISTRATION_VERIFY_EMAIL: not editable here
|
||||
RegistrationVerifyViaEnv bool `json:"registrationVerifyConfiguredViaEnv,omitempty"`
|
||||
// Mails with links to unknown people can be sent (SMTP and BASE_URL configured)
|
||||
EmailLinksAvailable bool `json:"emailLinksAvailable,omitempty"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||
// writing it empty keeps the stored password
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
|
||||
@@ -10,13 +10,23 @@ import (
|
||||
|
||||
// --- API KEYS & WEBHOOKS ---
|
||||
|
||||
// Scopes of an API key: what it may deliver. A key never reads data.
|
||||
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
|
||||
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
|
||||
// a key.
|
||||
const (
|
||||
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
||||
ScopeTask = "webhook:task" // tasks directly
|
||||
ScopeList = "webhook:list" // items into lists
|
||||
|
||||
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
|
||||
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
|
||||
ScopeListsRead = "lists:read" // MCP: lists and their items
|
||||
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
|
||||
)
|
||||
|
||||
// AllScopes in display order.
|
||||
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
|
||||
|
||||
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
|
||||
func NormalizeScopes(scopes []string) []string {
|
||||
want := map[string]bool{}
|
||||
@@ -24,7 +34,7 @@ func NormalizeScopes(scopes []string) []string {
|
||||
want[strings.ToLower(strings.TrimSpace(sc))] = true
|
||||
}
|
||||
out := []string{}
|
||||
for _, sc := range []string{ScopeTriage, ScopeTask, ScopeList} {
|
||||
for _, sc := range AllScopes {
|
||||
if want[sc] {
|
||||
out = append(out, sc)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ var migrations = []migration{
|
||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||
{8, "Journal", (*Store).migrateJournal},
|
||||
{9, "Web-Push", (*Store).migratePush},
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registration without an invite: who may register (mode), from which e-mail domains, and accounts
|
||||
// that wait for an admin's approval (they exist with is_active = 0 until approved).
|
||||
|
||||
const (
|
||||
RegistrationClosed = "closed" // only with an invite
|
||||
RegistrationApproval = "approval" // anyone, active after approval
|
||||
RegistrationDomain = "domain" // only listed e-mail domains, active at once
|
||||
RegistrationDomainApproval = "domain+approval" // listed domains at once, others after approval
|
||||
RegistrationOpen = "open" // anyone, active at once
|
||||
|
||||
// MaxPendingRegistrations stops a flood of sign-ups while approval is needed
|
||||
MaxPendingRegistrations = 50
|
||||
)
|
||||
|
||||
// NormalizeRegistrationMode returns a known mode, "" for anything else.
|
||||
func NormalizeRegistrationMode(mode string) string {
|
||||
switch m := strings.ToLower(strings.TrimSpace(mode)); m {
|
||||
case RegistrationClosed, RegistrationApproval, RegistrationDomain, RegistrationDomainApproval, RegistrationOpen:
|
||||
return m
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseEmailDomains turns "firma.de, @Familie.de" into ["firma.de", "familie.de"].
|
||||
func ParseEmailDomains(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) {
|
||||
d := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(part), "@"))
|
||||
if d == "" || !strings.Contains(d, ".") || seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EmailInDomains: the part after "@" equals one of the domains; "*.firma.de" also allows subdomains.
|
||||
func EmailInDomains(email string, domains []string) bool {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(email[at+1:]))
|
||||
for _, d := range domains {
|
||||
if strings.HasPrefix(d, "*.") {
|
||||
if base := d[2:]; host == base || strings.HasSuffix(host, "."+base) {
|
||||
return true
|
||||
}
|
||||
} else if host == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) migrateRegistration() error {
|
||||
for _, q := range []string{
|
||||
`ALTER TABLE user_invites ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1`,
|
||||
`ALTER TABLE user_invites ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0`,
|
||||
`UPDATE user_invites SET use_count = 1 WHERE used_by != '' AND use_count = 0`,
|
||||
`CREATE TABLE IF NOT EXISTS pending_registrations (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
} {
|
||||
if err := s.execSchemaStatement(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPendingRegistration(userID string) error {
|
||||
_, err := s.db.Exec(`INSERT OR IGNORE INTO pending_registrations (user_id, created_at) VALUES (?, ?)`,
|
||||
userID, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearPendingRegistration(userID string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM pending_registrations WHERE user_id = ?`, userID)
|
||||
}
|
||||
|
||||
func (s *Store) IsPendingRegistration(userID string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM pending_registrations WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// PendingRegistrationIDs: accounts that still wait for approval (only existing users).
|
||||
func (s *Store) PendingRegistrationIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := s.db.Query(`SELECT p.user_id FROM pending_registrations p JOIN users u ON u.id = p.user_id`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApprovePendingRegistration activates a waiting account; false when it was not waiting.
|
||||
func (s *Store) ApprovePendingRegistration(userID string) (bool, error) {
|
||||
if !s.IsPendingRegistration(userID) {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.execInTx(
|
||||
stmt(`UPDATE users SET is_active = 1 WHERE id = ?`, userID),
|
||||
stmt(`DELETE FROM pending_registrations WHERE user_id = ?`, userID),
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// --- E-mail verification ---
|
||||
// Accounts registered without invite while verification is on stay locked until the link in the
|
||||
// mail is opened. Only the SHA-256 of the token is stored (like password reset links); unconfirmed
|
||||
// accounts are deleted after EmailVerificationTTL so the address is free again.
|
||||
|
||||
const (
|
||||
EmailVerificationTTL = 48 * time.Hour
|
||||
emailVerificationCooldown = 2 * time.Minute // no second mail for the same account within this time
|
||||
)
|
||||
|
||||
var ErrVerificationInvalid = errors.New("Der Bestätigungslink ist ungültig oder abgelaufen. Bitte registriere dich erneut.")
|
||||
|
||||
func (s *Store) migrateEmailVerification() error {
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash)`),
|
||||
)
|
||||
}
|
||||
|
||||
// CreateEmailVerification returns a new raw token for the account (the previous one stops working),
|
||||
// or ErrResetTooSoon while the last mail is younger than the cooldown. The expiry of the account stays
|
||||
// counted from the registration.
|
||||
func (s *Store) CreateEmailVerification(userID string) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
var created, expires string
|
||||
err := s.db.QueryRow(`SELECT created_at, expires_at FROM email_verifications WHERE user_id = ?`, userID).Scan(&created, &expires)
|
||||
if err == nil {
|
||||
if t, perr := time.Parse(time.RFC3339, created); perr == nil && now.Sub(t) < emailVerificationCooldown {
|
||||
return "", ErrResetTooSoon
|
||||
}
|
||||
} else {
|
||||
expires = now.Add(EmailVerificationTTL).Format(time.RFC3339)
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := hex.EncodeToString(buf)
|
||||
_, err = s.db.Exec(`INSERT INTO email_verifications (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at`,
|
||||
userID, hashResetToken(raw), now.Format(time.RFC3339), expires)
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// ConfirmEmailVerification checks the token, ends the verification and returns the account.
|
||||
func (s *Store) ConfirmEmailVerification(raw string) (string, error) {
|
||||
var userID, expires string
|
||||
if err := s.db.QueryRow(`SELECT user_id, expires_at FROM email_verifications WHERE token_hash = ?`, hashResetToken(raw)).
|
||||
Scan(&userID, &expires); err != nil {
|
||||
return "", ErrVerificationInvalid
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().UTC().After(exp) {
|
||||
return "", ErrVerificationInvalid
|
||||
}
|
||||
res, err := s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ? AND token_hash = ?`, userID, hashResetToken(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return "", ErrVerificationInvalid // used in parallel
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *Store) IsEmailUnverified(userID string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM email_verifications WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (s *Store) ClearEmailVerification(userID string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ?`, userID)
|
||||
}
|
||||
|
||||
// UnverifiedEmailIDs: accounts whose address is not confirmed yet.
|
||||
func (s *Store) UnverifiedEmailIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := s.db.Query(`SELECT user_id FROM email_verifications`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PurgeUnverifiedAccounts deletes accounts whose confirmation link expired unused; returns how many.
|
||||
func (s *Store) PurgeUnverifiedAccounts(now time.Time) int {
|
||||
rows, err := s.db.Query(`SELECT user_id FROM email_verifications WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
if err := s.DeleteUser(id); err == nil {
|
||||
n++
|
||||
}
|
||||
s.ClearEmailVerification(id)
|
||||
s.ClearPendingRegistration(id)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unconfirmed accounts disappear once their link expired, so the address is free again
|
||||
func TestUnverifiedAccountsArePurged(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "verify.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
u, _ := store.CreateUser("neu@example.com", "hash", "Neu", "member")
|
||||
keep, _ := store.CreateUser("bleibt@example.com", "hash", "Bleibt", "member")
|
||||
if _, err := store.CreateEmailVerification(u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateEmailVerification(u.ID); err != ErrResetTooSoon {
|
||||
t.Fatalf("second mail within the cooldown must be refused, got %v", err)
|
||||
}
|
||||
if n := store.PurgeUnverifiedAccounts(time.Now()); n != 0 {
|
||||
t.Fatalf("nothing expired yet, purged %d", n)
|
||||
}
|
||||
if n := store.PurgeUnverifiedAccounts(time.Now().Add(EmailVerificationTTL + time.Hour)); n != 1 {
|
||||
t.Fatalf("want 1 purged, got %d", n)
|
||||
}
|
||||
if got, _ := store.GetUserByID(u.ID); got != nil {
|
||||
t.Fatal("unconfirmed account must be gone")
|
||||
}
|
||||
if got, _ := store.GetUserByID(keep.ID); got == nil {
|
||||
t.Fatal("other accounts stay")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
ErrBlockNotFound = errors.New("Punkt nicht gefunden")
|
||||
ErrBranchLocked = errors.New("Jemand anderes bearbeitet gerade einen Punkt in diesem Ast. Bitte kurz warten.")
|
||||
ErrMergeSelf = errors.New("Eine Notiz kann nicht mit sich selbst zusammengeführt werden")
|
||||
ErrAlreadyNote = errors.New("Dieser Punkt verweist schon auf eine eigene Notiz")
|
||||
ErrMergeNotOwner = errors.New("Zusammenführen geht nur mit eigenen Notizen als Quelle")
|
||||
)
|
||||
|
||||
@@ -37,6 +38,8 @@ func (s *Store) readCollabDoc(docID string) (*collab.Doc, error) {
|
||||
var (
|
||||
titlePrefixRe = regexp.MustCompile(`^(\s*(-|\*|#{1,6}|\[[ xX]?\])\s+)+`)
|
||||
titleTagRe = regexp.MustCompile(`(^|\s)#[\p{L}0-9_-]+`)
|
||||
// A bullet that is only a link to a note (with hashtags), as left behind by ExtractBranch
|
||||
noteLinkOnlyRe = regexp.MustCompile(`^\s*\[\[[^\]]+\]\](\s+#[\p{L}0-9_-]+)*\s*$`)
|
||||
)
|
||||
|
||||
// branchTitle makes a note title from a bullet's text (without hashtags) and returns the hashtags,
|
||||
@@ -116,6 +119,9 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
|
||||
return nil, "", ErrBlockNotFound
|
||||
}
|
||||
root := flat[start]
|
||||
if noteLinkOnlyRe.MatchString(root.Content) {
|
||||
return nil, "", ErrAlreadyNote
|
||||
}
|
||||
var children []domain.DocumentBlock
|
||||
ids := []string{root.ID}
|
||||
for i := start + 1; i < len(flat) && flat[i].Level > root.Level; i++ {
|
||||
|
||||
@@ -138,6 +138,12 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings.SharingMode = v
|
||||
case "allow_registration":
|
||||
settings.AllowRegistration = (v == "true" || v == "1")
|
||||
case "registration_mode":
|
||||
settings.RegistrationMode = NormalizeRegistrationMode(v)
|
||||
case "registration_domains":
|
||||
settings.RegistrationDomains = v
|
||||
case "registration_verify_email":
|
||||
settings.RegistrationVerifyEmail = v == "true"
|
||||
case "instance_name":
|
||||
settings.InstanceName = v
|
||||
case "smtp_host":
|
||||
@@ -173,10 +179,28 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older installations only knew the open/closed switch
|
||||
if settings.RegistrationMode == "" {
|
||||
settings.RegistrationMode = RegistrationClosed
|
||||
if settings.AllowRegistration {
|
||||
settings.RegistrationMode = RegistrationOpen
|
||||
}
|
||||
}
|
||||
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
// Clients that only send the old switch keep working
|
||||
settings.RegistrationMode = NormalizeRegistrationMode(settings.RegistrationMode)
|
||||
if settings.RegistrationMode == "" {
|
||||
settings.RegistrationMode = RegistrationClosed
|
||||
if settings.AllowRegistration {
|
||||
settings.RegistrationMode = RegistrationOpen
|
||||
}
|
||||
}
|
||||
settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen
|
||||
settings.RegistrationDomains = strings.Join(ParseEmailDomains(settings.RegistrationDomains), ", ")
|
||||
regStr := "false"
|
||||
if settings.AllowRegistration {
|
||||
regStr = "true"
|
||||
@@ -221,7 +245,10 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
('allow_private_webhooks', ?),
|
||||
('backup_enabled', ?),
|
||||
('backup_time', ?),
|
||||
('backup_retention_days', ?)
|
||||
('backup_retention_days', ?),
|
||||
('registration_mode', ?),
|
||||
('registration_domains', ?),
|
||||
('registration_verify_email', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
settings.SharingMode,
|
||||
regStr,
|
||||
@@ -238,6 +265,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
strconv.FormatBool(settings.BackupEnabled),
|
||||
settings.BackupTime,
|
||||
strconv.Itoa(settings.BackupRetentionDays),
|
||||
settings.RegistrationMode,
|
||||
settings.RegistrationDomains,
|
||||
strconv.FormatBool(settings.RegistrationVerifyEmail),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -327,13 +327,25 @@ func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
|
||||
// --- USER INVITES ---
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
|
||||
// MaxInviteUses caps how many accounts one invite link can create.
|
||||
const MaxInviteUses = 1000
|
||||
|
||||
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays, maxUses int) (*domain.UserInvite, error) {
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
}
|
||||
if validDays <= 0 || validDays > 365 {
|
||||
validDays = 7
|
||||
}
|
||||
if maxUses < 1 {
|
||||
maxUses = 1
|
||||
}
|
||||
if maxUses > MaxInviteUses {
|
||||
maxUses = MaxInviteUses
|
||||
}
|
||||
if maxUses > 1 && strings.TrimSpace(email) != "" {
|
||||
return nil, errors.New("Ein Einladungslink für mehrere Personen kann nicht an eine E-Mail-Adresse gebunden werden")
|
||||
}
|
||||
code := newInviteCode()
|
||||
now := time.Now().UTC()
|
||||
inv := &domain.UserInvite{
|
||||
@@ -345,11 +357,12 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
||||
CreatedBy: createdBy,
|
||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
MaxUses: maxUses,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, 0)`,
|
||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, inv.MaxUses,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -359,7 +372,8 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
||||
|
||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
|
||||
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at,
|
||||
i.max_uses, i.use_count
|
||||
FROM user_invites i
|
||||
LEFT JOIN organizations o ON o.id = i.organization_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
@@ -372,7 +386,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||
invites := make([]domain.UserInvite, 0)
|
||||
for rows.Next() {
|
||||
var inv domain.UserInvite
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
|
||||
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount); err == nil {
|
||||
invites = append(invites, inv)
|
||||
}
|
||||
}
|
||||
@@ -389,17 +403,28 @@ func newInviteCode() string {
|
||||
}
|
||||
|
||||
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
return s.getUserInvite(`UPPER(code) = ?`, strings.ToUpper(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
// GetUserInviteByID: a still usable invite by its id (e.g. to send it by mail).
|
||||
func (s *Store) GetUserInviteByID(id string) (*domain.UserInvite, error) {
|
||||
return s.getUserInvite(`id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *Store) getUserInvite(where string, arg string) (*domain.UserInvite, error) {
|
||||
var inv domain.UserInvite
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
|
||||
FROM user_invites WHERE UPPER(code) = ?`,
|
||||
code,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count
|
||||
FROM user_invites WHERE `+where,
|
||||
arg,
|
||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount)
|
||||
if err != nil {
|
||||
return nil, errors.New("Ungültiger Einladungscode")
|
||||
}
|
||||
if inv.UsedBy != "" {
|
||||
if inv.UseCount >= inv.MaxUses {
|
||||
if inv.MaxUses > 1 {
|
||||
return nil, errors.New("Dieser Einladungslink ist bereits voll ausgeschöpft")
|
||||
}
|
||||
return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) {
|
||||
@@ -408,30 +433,36 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
|
||||
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
|
||||
// ReleaseUserInvite.
|
||||
// ClaimUserInvite reserves one use of an invite before the account is created, so registrations with
|
||||
// the same code can never use it more often than allowed, even at the same moment (FLAW-40). The
|
||||
// returned marker goes to FinishUserInvite or ReleaseUserInvite.
|
||||
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
|
||||
claim := domain.NewID("claim")
|
||||
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
|
||||
res, err := s.db.Exec(`UPDATE user_invites SET use_count = use_count + 1 WHERE id = ? AND use_count < max_uses`, inviteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
||||
}
|
||||
return claim, nil
|
||||
return domain.NewID("claim"), nil
|
||||
}
|
||||
|
||||
// FinishUserInvite records the new account on a claimed invite.
|
||||
// FinishUserInvite records the new account on a single-use invite (links for several people only count).
|
||||
func (s *Store) FinishUserInvite(inviteID, claim, userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim)
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND max_uses = 1`, userID, inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReleaseUserInvite frees a claimed invite again when the account could not be created.
|
||||
// ReleaseUserInvite gives the reserved use back when the account could not be created.
|
||||
func (s *Store) ReleaseUserInvite(inviteID, claim string) {
|
||||
_, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim)
|
||||
_, _ = s.db.Exec(`UPDATE user_invites SET use_count = use_count - 1 WHERE id = ? AND use_count > 0`, inviteID)
|
||||
}
|
||||
|
||||
// BindUserInviteEmail ties a single-use invite to the address it is sent to.
|
||||
func (s *Store) BindUserInviteEmail(inviteID, email string) error {
|
||||
_, err := s.db.Exec(`UPDATE user_invites SET email = ? WHERE id = ? AND max_uses = 1 AND email = ''`,
|
||||
strings.ToLower(strings.TrimSpace(email)), inviteID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||||
|
||||
|
Before Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 58 KiB |