Merge pull request 'Dev' (#10) from dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
sandrodach committed 2026-10-28 22:46:13 +01:00
commit e74661e0c7
76 files changed
+4474 -1170

No files matched your search

+2
View File
@@ -17,3 +17,5 @@ SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
SMTP_TLS=true
# Only for internal mail servers with self-signed certificates
SMTP_SKIP_VERIFY=false
+1
View File
@@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
| [theming.md](theming.md) | Themes und Design-Variablen |
| [ui-patterns.md](ui-patterns.md) | Einheitliche Bedienelemente: Personensuche, Profilbild-Stapel, Zähler |
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
## Überblick in 60 Sekunden
+14 -9
View File
@@ -7,7 +7,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode` |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
@@ -26,10 +26,12 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard |
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
| GET | `/export` | Datenexport als JSON |
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
## Gedanken, Triage, Aufgaben
@@ -38,7 +40,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
@@ -130,21 +132,24 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin, die Gruppe gehört zu seiner Organisation; im Modus `private` gesperrt |
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`); nur sichtbare Personen, sonst `404` |
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
## Administration (`role = admin`)
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`, optional `organizationId`) |
| 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 |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
## Beispiele
+21
View File
@@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
@@ -96,3 +97,23 @@ Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
## App-Icons, PWA & Social-Preview (Open Graph)
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
- **Design & Geometrie:** Sanft abgerundetes Quadrat (`rx=108` auf 512x512 Canvas bzw. `rounded-xl`), Primärblau (`--accent-primary: #2563eb`) als Hintergrund und zentriertes Lucide Brain Icon in reinem Weiß (`--text-on-accent: #ffffff`).
### Asset-Matrix (`frontend/public/`)
| Datei | Zweck & Zielplattform | Besonderheit |
|---|---|---|
| `icon.svg` | Modernes Standard-Icon & SVG-Favicon | Vektor-Original mit abgerundeten Ecken |
| `icon-192x192.png`, `icon-512x512.png` | PWA-Installation (`purpose: "any"`), Desktop Linux/Windows | Erforderlich für Chromium unter Linux (erzeugt sonst kein `.desktop`-Icon) |
| `icon-maskable-192x192.png`, `icon-maskable-512x512.png`, `icon-maskable.svg` | Android Adaptive Icons (`purpose: "maskable"`) | Full-Bleed Hintergrund (`#2563eb`) ohne transparente Ecken, Brain im 80%-Sicherheitsbereich |
| `apple-touch-icon.png` (180x180) | iOS Safari („Zum Home-Bildschirm“) | Opaker Hintergrund verhindert schwarze Ränder beim automatischen Apple-Squircle-Maskieren |
| `favicon.ico`, `favicon-32x32.png`, `favicon-16x16.png` | Browser-Tabs, Lesezeichen, Chronik | Multi-Resolution-ICO (16/32/48) für alle Desktop-Browser |
| `og-image.png` (1200x630), `og-image-square.png` (512x512) | Link-Vorschau in WhatsApp, Discord, Telegram, X/Twitter | Dunkler `#0b0f19`-Hintergrund mit Icon & Claim; Datei-Größe < 120 KB (WhatsApp-Limit < 300 KB) |
### Dynamische OpenGraph-URL-Injektion
Messenger-Crawler (insbesondere WhatsApp) werten relative Bild-URLs in `og:image` oft nicht korrekt aus. Der Go-SPA-Handler in [`internal/api/server.go`](../internal/api/server.go) ersetzt beim Ausliefern von `index.html` automatisch relative Pfade (`/og-image.png`, `og:url`) durch die absolute Server-Origin (`scheme://host`), ermittelt über `X-Forwarded-Proto` und `X-Forwarded-Host` bzw. `req.Host`.
+10 -8
View File
@@ -9,9 +9,10 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
### Benutzer und Zugang
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
@@ -19,7 +20,8 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
@@ -48,14 +50,14 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
|---|---|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by` (leer bei alten Einträgen), `sort_order` |
### Gruppen
| Tabelle | Zweck |
|---|---|
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
| `teams` | Gruppen der Benutzer: `name`, `description`, `org_id` (Organisation des Erstellers) |
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
| `organizations` | Vom Server-Admin angelegt: `name`. Mitglieder über `users.organization_id` (höchstens eine pro Benutzer) |
## Beziehungen
@@ -77,8 +79,8 @@ teams 1─n team_members n─1 users
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft.
## Sicherung
+7 -3
View File
@@ -15,11 +15,11 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
@@ -33,7 +33,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
| FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
@@ -70,6 +70,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-06: Editor kann Aufgabenliste weiterteilen
- **Status: Behoben.** Das Feld `task_lists.team_id` ist entfernt; Freigaben gibt es nur noch über `task_list_shares`, und die legt nur der Eigentümer an.
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
@@ -90,6 +91,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
- **Status: Behoben.** Modi `private`/`organizations`/`open`, zentral durchgesetzt über `Store.CanSee`/`CanShareWith` in Verzeichnis, allen Freigaben und Gruppen; Rechte werden bei Sichtbarkeitsverlust bereinigt ([sharing-and-permissions.md](sharing-and-permissions.md)). Test: `internal/storage/visibility_test.go`.
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
- **Problem:**
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
@@ -113,6 +115,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-13: Migrationen
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`). Ist eine Datenbank älter, wird sie beim Start **komplett gelöscht** und neu angelegt (vor dem produktiven Einsatz bewusst so gewählt). Die übrigen Punkte unten gelten weiter.
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
- **Probleme:**
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
@@ -171,6 +174,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
- **Status: Behoben.** Die alten `/organizations`-Endpunkte sind entfernt; Organisationen verwaltet nur der Server-Admin über `/admin/organizations`.
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
+3 -1
View File
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
## Datenmodell
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by`, `sort_order`.
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
## Funktionen
@@ -16,4 +16,6 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
- **Wer hat's hinzugefügt:** In geteilten Listen zeigt jeder Eintrag ein kleines Profilbild der Person, die ihn angelegt hat (`createdBy`, `createdByName`). Einträge von vor dieser Funktion haben keinen Autor und zeigen nichts.
- **Tabs** zeigen offene Einträge als runden Zähler und die Personen mit Zugriff als Profilbild-Stapel ([ui-patterns.md](../ui-patterns.md)); die API liefert sie je Liste als `members`.
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
+15 -3
View File
@@ -4,7 +4,7 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
## Datenmodell
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
## Ansichten
@@ -21,7 +21,11 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
- **Zuweisen** (nur in geteilten Listen, also mit mindestens einer Person außer dem Eigentümer): Im Detail-Fenster unter „Zugewiesen“ lassen sich eine oder mehrere Personen mit Zugriff auf die Liste zuweisen, über die einheitliche Personensuche ([ui-patterns.md](../ui-patterns.md)). Zuweisen dürfen Eigentümer und Bearbeiter. Zeilen und Kanban-Karten zeigen die Zugewiesenen als Profilbild-Stapel.
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
## Alarme
@@ -29,10 +33,18 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
| Kanal | Ziel | Konfiguration |
|---|---|---|
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung, siehe unten |
| Discord | Webhook-URL | Benutzereinstellungen |
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
+1
View File
@@ -6,6 +6,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
- **Hashtag-Regel:** `#wort` zählt nur am Textanfang oder nach Leerzeichen bzw. `(`. Anker in Links (`example.com/blog#ueberschrift`) und Wörter wie `C#` sind keine Tags. Gleiche Regel im Server (`domain.ExtractTags`) und im Browser (`lib/hashtags.ts`). Beim ersten Start nach der Umstellung entfernt `migrateAnchorTags` einmalig gespeicherte Tags, die nur aus Ankern stammten.
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
+28 -10
View File
@@ -4,7 +4,8 @@
| Ebene | Werte | Bedeutung |
|---|---|---|
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
| Server | `admin`, `member` | Admins verwalten Benutzer, Organisationen, Einladungen und Einstellungen (`/admin/*`). |
| Organisation | Mitglied | Bestimmt, wer wen sieht (siehe unten). Nur Server-Admins ordnen zu. |
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
@@ -20,7 +21,7 @@
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
- **Aufgaben-Zuweisungen** hängen am Zugriff: Verliert jemand den letzten Zugriffsweg auf eine Aufgabenliste, entfernt der Server ihn aus allen Zuweisungen dieser Liste ([modules/tasks.md](modules/tasks.md)).
## Aufgabenlisten teilen (Details)
@@ -31,17 +32,34 @@
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
## Isolations-Modi (`system_settings.sharing_mode`)
## Sichtbarkeit: Organisationen und Modi (`system_settings.sharing_mode`)
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
Zwei Begriffe, nicht verwechseln:
| Modus | Zweck |
| Begriff | Verwaltet von | Zweck |
|---|---|---|
| **Organisation** | nur Server-Admin (`/admin/organizations`) | Legt fest, wer wen **sehen** darf. Jeder Benutzer gehört höchstens einer an (`users.organization_id`). |
| **Gruppe** (Code: `teams`) | Benutzer selbst | Abkürzung beim Teilen („teile mit meiner WG“). Erweitert die Sichtbarkeit nie. |
| Modus | Wer sieht wen |
|---|---|
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
| `open` | Jeder kann für jeden freigeben |
| `private` | Niemand. Benutzersuche, Teilen und Gruppen sind aus (Server antwortet mit Fehler, UI blendet sie aus). |
| `organizations` (Standard) | Nur Mitglieder derselben Organisation. Wer keiner angehört, sieht niemanden. |
| `open` | Alle. |
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
**Eine Regel für alles:** `Store.CanSee(a, b)` und `Store.CanShareWith(owner, typ, ziel)` in [visibility.go](../internal/storage/visibility.go). Darüber laufen Benutzerverzeichnis (`/users/directory`, `/collaborators`), jede Freigabe (Notiz, Ordner, Aufgabenliste, Liste) und das Hinzufügen zu Gruppen. Server-Admins folgen beim Teilen derselben Regel; alle Konten verwalten sie über `/admin/*`.
- Mit einer Gruppe teilen darf nur, wer selbst Mitglied ist. Eine Gruppe gehört zur Organisation ihres Erstellers (`teams.org_id`), Mitglieder kommen nur von dort.
- Unbekannte und nicht sichtbare Personen liefern dieselbe Meldung („Benutzer oder Gruppe nicht gefunden“), damit niemand Konten per E-Mail ausprobieren kann.
- **Teilen geht nur vom Eigentümer.** Bearbeiter können nicht weiterteilen und keine fremden Freigaben entfernen; jeder kann aber seine eigene Freigabe entfernen (eine geteilte Notiz oder einen Ordner verlassen).
**Rechte folgen der Sichtbarkeit.** `Store.PruneSharing` entfernt alles, was unter dem aktuellen Modus nicht mehr erlaubt wäre: Gruppen-Mitgliedschaften außerhalb der Organisation der Gruppe, Freigaben an Personen, die der Eigentümer nicht mehr sieht, Freigaben an Gruppen, in denen der Eigentümer nicht mehr ist; danach leere Gruppen und über `PruneTaskAssignees` die Aufgaben-Zuweisungen. Gruppen ohne Admin bekommen einen. Es läuft nach:
- Wechsel oder Entfernen der Organisation eines Benutzers, Löschen einer Organisation
- Wechsel des Modus durch den Admin (vorher zeigt `GET /admin/settings/sharing-impact?mode=…` als Probelauf, wie viel wegfällt)
- Austritt aus oder Löschen einer Gruppe, Löschen eines Kontos
Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Namen der Instanz angelegt und der erste Admin ihr zugeordnet. Einladungen und „Benutzer anlegen“ können eine Organisation mitgeben. Im Demo-Modus sind `admin` und `user` in der Organisation „Demo“.
## Entkoppelte Ablage geteilter Notizen und Ordner
@@ -52,4 +70,4 @@ Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
+21 -1
View File
@@ -97,11 +97,31 @@ In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
### 🏷️ App-Icon & Branding
Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-Icons und Social-Preview-Bilder) folgt der primären Akzentfarbe `--accent: #2563eb` und `--text-on-accent: #ffffff` mit den typischen leichten Eckenrundungen (`rounded-xl` bzw. `rx=108`). Details zur Asset-Matrix und den Icon-Formaten siehe [architecture.md](architecture.md#app-icons-pwa--social-preview-open-graph).
---
## 🛠️ 5. Ein eigenes Theme erstellen
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
### Ohne Rebuild: Theme-Manager in den Kontoeinstellungen
Unter **Kontoeinstellungen → Darstellung & Themes** kann jeder Benutzer eigene Themes anlegen:
- **Vorlage** herunterladen, kopieren oder direkt ins Eingabefeld einfügen (`theme-template.css`).
- CSS als **`.css`-Datei hochladen** oder **einfügen**, Namen vergeben, „Speichern & aktivieren“.
- Eigene Themes erscheinen als Kacheln in der Galerie und im Profil-Dropdown, mit **Bearbeiten** und **Löschen**.
Technik:
- Gespeichert pro Benutzer in der Tabelle `user_themes` (API `GET/POST /themes`, `DELETE /themes/{id}`, max. 200 KB), also im Daten-Volume und auf allen Geräten verfügbar.
- Aktiv gesetzt wird ein eigenes Theme als `custom:<id>`. [`theme.ts`](../frontend/src/lib/theme.ts) setzt das CSS live als `<style id="mindsift-custom-theme">` ans Ende von `<head>` (gewinnt damit gegen die eingebauten Themes) und übernimmt den Wert aus dem Selektor `[data-theme="…"]` des CSS für `<html data-theme>`. Ohne solchen Selektor gilt das CSS für `:root`. Kommentare werden dabei ignoriert.
- Ob die Tailwind-Klasse `dark` gesetzt wird, entscheidet die Helligkeit von `--bg-app`.
- Das CSS des aktiven Themes liegt zusätzlich im `localStorage`, damit es beim Start sofort greift; nach dem Login wird es mit dem Server abgeglichen. Wird es gelöscht, fällt die Ansicht auf „Dark“ zurück.
### Eingebautes Theme (mit Rebuild)
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") fest in MindSift einzubauen:
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
3. Passe Farben und Rundungen nach Belieben an.
+48
View File
@@ -0,0 +1,48 @@
# UI-Muster
Wiederkehrende Bedienelemente, die überall gleich aussehen und sich gleich verhalten. Wer eines davon neu braucht, nimmt die vorhandene Komponente und baut keine eigene Variante.
## Personen suchen (Type-to-Search)
Komponente: [UserAutocomplete.tsx](../frontend/src/features/UserAutocomplete.tsx)
Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`GroupsView`) und beim Zuweisen von Aufgaben (`TaskDetailModal`).
- **Keine Liste vorab:** Solange das Feld leer ist, erscheint nichts, auch nicht beim Reinklicken. Kein Dropdown mit allen Personen.
- **Erst beim Tippen** erscheinen Treffer direkt unter dem Feld, **höchstens 3** (`MAX_RESULTS`). Gesucht wird in Anzeigename, `@Benutzername` und E-Mail.
- Der **erste Treffer ist vorausgewählt**: Tippen + Enter übernimmt ihn. Pfeiltasten wechseln, Escape schließt.
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×.
- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei.
## Profilbilder als Stapel
Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt `UserAvatar`)
- Kleine runde Profilbilder, **leicht überlappend**, **höchstens 3**. Weitere Personen erscheinen als monochromer Kreis „+n“.
- Ohne Profilbild zeigt `UserAvatar` die Initialen.
- `ringClass` bekommt die Farbe der Fläche dahinter (meist `ring-[var(--bg-panel)]` oder `ring-[var(--bg-app)]`), damit die Kreise sauber getrennt sind.
- Größen: `xxs` (16 px) in Tabs, Kanban-Karten und Listeneinträgen, `xs` (20 px) in Aufgabenzeilen und Kopfzeilen.
- **Kein Hover-Effekt** im Stapel selbst. Einzige Ausnahme ist die Anwesenheit in Notizen ([PresenceAvatars.tsx](../frontend/src/features/workspace/PresenceAvatars.tsx)): Dort zeigt Hover oder Tastaturfokus die Liste „Gerade online“.
| Ort | Wer wird gezeigt |
|---|---|
| Notiz-Kopfzeile | wer die Notiz gerade geöffnet hat (Profilbilder aus `/users/directory`) |
| Tabs der Aufgabenlisten und Listen | alle mit Zugriff außer mir (`members`) |
| Aufgabenzeile und Kanban-Karte | zugewiesene Personen (nur in geteilten Listen) |
| Eintrag einer geteilten Liste | wer ihn hinzugefügt hat (`createdBy`); ältere Einträge ohne Autor zeigen nichts |
## Schalter
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
## Zähler
CSS-Klasse `badge-count` in [index.css](../frontend/src/index.css): runder Zähler in der Form der Zähler der Navigationsleiste, aber **monochrom** (grau gefüllt) statt in der Akzentfarbe.
- Verwendet für die Anzahl offener Einträge in den Tabs der Aufgabenlisten und der Listen.
- Die Zähler der Navigationsleiste bleiben blau (Akzent), weil sie auf neue Inhalte aufmerksam machen.
- Kein eckiges `badge-neutral` mehr für Zähler in Tabs.
+1
View File
@@ -156,6 +156,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
| `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_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
+1 -1
View File
@@ -38,7 +38,7 @@ func main() {
defer store.Close()
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
+1
View File
@@ -26,3 +26,4 @@ services:
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
+1
View File
@@ -26,3 +26,4 @@ services:
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
+2 -1
View File
@@ -84,7 +84,8 @@ const main = async () => {
const B = await openDoc(browser, user, TITLE);
// Presence
const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000);
// Presence is an avatar stack; its label lists who is online
const presence = await waitFor(async () => B.page.getByLabel(/^Gerade online:/).getAttribute('aria-label'), 8000);
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
// Admin clicks into the first bullet -> user sees the lock
+1 -2
View File
@@ -69,8 +69,7 @@ const main = async () => {
await search.click();
await search.type('user');
await sleep(300);
await search.press('ArrowDown');
await search.press('Enter');
await search.press('Enter'); // the first match is preselected
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
await roleSelect.selectOption('editor');
+28 -2
View File
@@ -9,9 +9,35 @@
<meta name="apple-mobile-web-app-title" content="MindSift" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
<link rel="manifest" href="/manifest.json" />
<!-- Favicon & Touch Icons -->
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Open Graph / WhatsApp / Discord / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="MindSift" />
<meta property="og:url" content="/" />
<meta property="og:title" content="MindSift — Der Kopf frei in drei Sekunden" />
<meta property="og:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:secure_url" content="/og-image.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="MindSift App Logo & Vorschau" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="MindSift — Der Kopf frei in drei Sekunden" />
<meta name="twitter:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="MindSift App Logo & Vorschau" />
<link rel="manifest" href="/manifest.json" />
<title>MindSift</title>
<script>
window.__deferredPwaPrompt = null;
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#2563eb"/>
<g transform="translate(256, 256) scale(11) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 861 B

+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="512" height="512" rx="108" fill="#2563eb"/>
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
@@ -11,4 +11,4 @@
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 851 B

After

Width:  |  Height:  |  Size: 872 B

+26 -2
View File
@@ -17,8 +17,32 @@
"purpose": "any"
},
{
"src": "/icon.svg",
"sizes": "192x192 512x512",
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+51 -14
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Layers,
CheckSquare,
@@ -24,7 +24,7 @@ import {
Mic,
MicOff,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
import {
api,
clearToken,
@@ -32,10 +32,13 @@ import {
triggerHaptic,
connectWebSocket,
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import {
AVAILABLE_THEMES,
customThemeOption,
initThemeOnBoot,
isCustomThemeId,
setActiveTheme as applyTheme,
ThemeId,
} from './lib/theme';
@@ -114,6 +117,8 @@ export const App: React.FC = () => {
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null);
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0,
tasksOpenCount: 0,
@@ -226,7 +231,7 @@ export const App: React.FC = () => {
useEffect(() => {
closeMenu();
if (activeTab === 'triage') {
setPrimaryAction(() => openMobileTriageCapture);
setPrimaryAction(openMobileTriageCapture);
setGroups([
{
id: 'triage-actions',
@@ -273,12 +278,16 @@ export const App: React.FC = () => {
badge: stats.listsCount,
onClick: () => setActiveTab('lists'),
},
{
id: 'goto-groups',
label: 'Teams & Gruppen',
icon: <Users className="w-4 h-4" />,
onClick: () => setActiveTab('groups'),
},
...(isSharingDisabled()
? []
: [
{
id: 'goto-groups',
label: 'Gruppen',
icon: <Users className="w-4 h-4" />,
onClick: () => setActiveTab('groups'),
},
]),
],
},
]);
@@ -411,12 +420,33 @@ export const App: React.FC = () => {
setShowProfileMenu(false);
};
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
applyTheme(themeId, css);
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
};
const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10);
applyTheme(themeId);
setActiveThemeState(themeId);
activateTheme(themeId, customThemes);
};
// Custom themes live on the server; after login refresh them and the active one (it may have
// been edited or deleted on another device)
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
setCustomThemes(themes);
const next = activate ?? activeTheme;
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
};
useEffect(() => {
if (!user?.id) return;
api
.getThemes()
.then((themes) => handleCustomThemesChange(themes))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id]);
const refreshAll = useCallback(async () => {
try {
const [meRes, deckRes, listsRes] = await Promise.all([
@@ -618,7 +648,7 @@ export const App: React.FC = () => {
tasks: 'Aufgaben',
workspace: 'Workspace',
lists: 'Listen',
groups: 'Teams & Gruppen',
groups: 'Gruppen',
user_settings: 'Einstellungen',
admin_settings: 'Administration',
help: 'Hilfe',
@@ -765,7 +795,7 @@ export const App: React.FC = () => {
<span>Design-Theme</span>
</span>
<div className="grid grid-cols-2 gap-1">
{AVAILABLE_THEMES.map((t) => (
{allThemes.map((t) => (
<button
key={t.id}
type="button"
@@ -802,6 +832,7 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
onClick={() => {
triggerHaptic(10);
@@ -815,6 +846,7 @@ export const App: React.FC = () => {
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
onClick={handleOpenHelp}
@@ -1002,7 +1034,10 @@ export const App: React.FC = () => {
<UserSettingsView
currentUser={user}
activeTheme={activeTheme}
themes={allThemes}
customThemes={customThemes}
onThemeChange={handleChangeTheme}
onCustomThemesChange={handleCustomThemesChange}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
@@ -1313,7 +1348,7 @@ export const App: React.FC = () => {
<span>Design-Theme</span>
</span>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_THEMES.map((t) => (
{allThemes.map((t) => (
<button
key={t.id}
type="button"
@@ -1367,6 +1402,7 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
type="button"
onClick={() => {
@@ -1381,6 +1417,7 @@ export const App: React.FC = () => {
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
type="button"
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
import { UserAvatar } from './UserAvatar';
export interface AvatarPerson {
id: string;
name: string;
avatarUrl?: string;
}
interface AvatarStackProps {
people: AvatarPerson[];
max?: number;
size?: 'xxs' | 'xs';
// Ring in the color of the surface behind the stack, so overlapping circles stay separated
ringClass?: string;
title?: string;
className?: string;
}
const OVERFLOW_SIZE = {
xxs: 'w-4 h-4 text-[7px]',
xs: 'w-5 h-5 text-[9px]',
};
// Small overlapping profile pictures: at most `max` circles, the rest as a monochrome "+n" circle.
// Used for presence in notes, list/task-list tabs, task assignees and list item authors (Doku/ui-patterns.md).
export const AvatarStack: React.FC<AvatarStackProps> = ({
people,
max = 3,
size = 'xs',
ringClass = 'ring-[var(--bg-app)]',
title,
className = '',
}) => {
if (people.length === 0) return null;
const visible = people.slice(0, max);
const hidden = people.length - visible.length;
return (
<span className={`inline-flex items-center -space-x-1.5 shrink-0 ${className}`} title={title}>
{visible.map((p) => (
<UserAvatar
key={p.id}
name={p.name}
avatarUrl={p.avatarUrl || ''}
size={size}
showTitle={false}
className={`ring-2 ${ringClass}`}
/>
))}
{hidden > 0 && (
<span
className={`relative inline-flex items-center justify-center rounded-full font-bold leading-none bg-muted text-panel ring-2 ${ringClass} ${OVERFLOW_SIZE[size]}`}
>
+{hidden}
</span>
)}
</span>
);
};
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
title?: string;
disabled?: boolean;
}
// On/off switch for settings; used instead of checkboxes (Doku/ui-patterns.md)
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange, title, disabled = false }) => (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-default ${
checked ? 'bg-accent' : 'bg-panel-elevated border border-border'
}`}
title={title}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
);
@@ -11,6 +11,7 @@ import {
import { User, Team, UnifiedShare } from '../lib/types';
import { UserAutocomplete } from '../features/UserAutocomplete';
import { UserAvatar } from './UserAvatar';
import { isSharingDisabled } from '../lib/api';
export interface UnifiedShareModalProps {
isOpen: boolean;
@@ -147,7 +148,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
)}
{/* Add Share Section (Only if owner / manager) */}
{isOwner ? (
{isSharingDisabled() ? (
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted">
Teilen ist auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).
</div>
) : isOwner ? (
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
+5 -2
View File
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
} | null;
name?: string;
avatarUrl?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
alt?: string;
showTitle?: boolean; // native name tooltip on hover
}
const SIZE_MAP = {
xxs: 'w-4 h-4 text-[7px]',
xs: 'w-5 h-5 text-[10px]',
sm: 'w-7 h-7 text-xs',
md: 'w-9 h-9 text-sm',
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
size = 'md',
className = '',
alt,
showTitle = true,
}) => {
const [imageError, setImageError] = useState(false);
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
return (
<div
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
title={effectiveName || alt}
title={showTitle ? effectiveName || alt : undefined}
>
{hasImage ? (
<img
+13 -9
View File
@@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
import { UserAvatar } from '../components/UserAvatar';
// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until
// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md.
const MAX_RESULTS = 3;
interface UserAutocompleteProps {
users: User[];
selectedUser: User | null;
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
// Filter users based on query and exclusions
const filteredUsers = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const available = users.filter((u) => !excludeUserIds.includes(u.id));
if (!q) {
return available.slice(0, 15);
}
return available
.filter((u) => {
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
const userMatch = (u.username || '').toLowerCase().includes(q);
return nameMatch || emailMatch || userMatch;
})
.slice(0, 15);
.slice(0, MAX_RESULTS);
}, [users, query, excludeUserIds]);
const showResults = isOpen && query.trim() !== '';
const handleSelect = (user: User) => {
onSelectUser(user);
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
if (!showResults) {
if (e.key === 'ArrowDown') {
setIsOpen(true);
}
return;
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
// First match is preselected so Enter picks it right away
setHighlightedIndex(0);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
@@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/>
{isOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{showResults && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{filteredUsers.length === 0 ? (
<div className="p-3 text-xs text-muted text-center">
Kein passender Benutzer gefunden
+20 -30
View File
@@ -11,8 +11,9 @@ import {
Check,
AlertCircle,
} from 'lucide-react';
import { AuthStatus, User } from '../../lib/types';
import { AuthStatus, SharingMode, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
@@ -29,9 +30,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [setupPassword, setSetupPassword] = useState('');
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
'teams_only'
);
const [sharingMode, setSharingMode] = useState<SharingMode>('organizations');
const [allowRegistration, setAllowRegistration] = useState(false);
const [createSampleData, setCreateSampleData] = useState(false);
@@ -241,31 +240,31 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSharingMode('strict_private')}
onClick={() => setSharingMode('private')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'strict_private'
sharingMode === 'private'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Strikt Privat</p>
<p className="font-bold text-xs">Privat</p>
<p className="text-[10px] text-muted mt-0.5">
Keiner sieht andere Nutzer. Komplett isoliert.
Keiner sieht andere Nutzer. Teilen und Gruppen sind aus.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('teams_only')}
onClick={() => setSharingMode('organizations')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'teams_only'
sharingMode === 'organizations'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Teams isoliert</p>
<p className="font-bold text-xs">Organisationen</p>
<p className="text-[10px] text-muted mt-0.5">
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
Nutzer sehen nur Mitglieder ihrer Organisation. Du startest in einer Organisation mit dem Namen der Instanz.
</p>
</button>
@@ -287,7 +286,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
</div>
<div className="pt-2 border-t border-border space-y-2">
<label className="flex items-center justify-between cursor-pointer select-none">
<div className="flex items-center justify-between gap-3 select-none">
<div>
<span className="font-medium text-main block">
Öffentliche Selbstregistrierung erlauben
@@ -297,30 +296,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
Admin angelegt werden.
</span>
</div>
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<ToggleSwitch checked={allowRegistration} onChange={setAllowRegistration} />
</div>
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
<div className="flex items-center justify-between gap-3 select-none pt-1">
<div>
<span className="font-medium text-main block">
Start-Beispieldaten anlegen
Beispieldaten anlegen
</span>
<span className="text-[11px] text-muted">
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
Füllt dein Konto mit Beispiel-Gedanken, Notizen, Aufgabenlisten und Einkaufslisten zum Ausprobieren.
Es werden keine weiteren Benutzer angelegt.
</span>
</div>
<input
type="checkbox"
checked={createSampleData}
onChange={(e) => setCreateSampleData(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<ToggleSwitch checked={createSampleData} onChange={setCreateSampleData} />
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -13,6 +13,7 @@ import {
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
@@ -144,10 +145,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
.catch(() => {});
}, []);
// People shown in tabs: everyone with access except me
const otherMembers = (lst: SharedList): AvatarPerson[] =>
(lst.members || [])
.filter((m) => m.userId !== currentUser?.id)
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
const activeList = useMemo(() => {
return lists.find((l) => l.id === activeListId) || lists[0] || null;
}, [lists, activeListId]);
// In shared lists every item shows who added it (older items without an author show nothing)
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
const renderItemAuthor = (item: ListItem) => {
if (!isActiveListShared || !item.createdBy) return null;
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
const name = member?.displayName || member?.email || item.createdByName || '';
return (
<AvatarStack
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
size="xxs"
ringClass="ring-transparent"
title={name ? `Hinzugefügt von ${name}` : undefined}
/>
);
};
useEffect(() => {
if (!activeList?.id) return;
@@ -825,26 +848,33 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<div key={lst.id} className="relative group flex items-center">
<div
key={lst.id}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
>
<button
onClick={() => setActiveListId(lst.id)}
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
isSelected
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
} ${isSelected && lists.length > 1 ? 'rounded-l-btn rounded-r-none border-r-0' : 'rounded-btn'}`}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
{openCount > 0 && (
<span className="ml-1 badge-neutral">{openCount}</span>
<span className="ml-1 badge-count">{openCount}</span>
)}
<AvatarStack
people={otherMembers(lst)}
size="xxs"
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
</button>
{isSelected && lists.length > 1 && (
<button
onClick={() => handleDeleteList(lst.id)}
title="Liste löschen"
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
@@ -1266,6 +1296,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
{renderItemAuthor(item)}
<button
type="button"
draggable={false}
@@ -1374,6 +1405,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
<div className="flex items-center gap-2">
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
)}
@@ -13,22 +13,47 @@ import {
Send,
AlertCircle,
UserPlus,
Building2,
Pencil,
} from 'lucide-react';
import {
Organization,
SharingMode,
SystemSettings,
User,
UserInvite,
UserRole,
} from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface AdminSettingsViewProps {
currentUser: User;
onBack: () => void;
}
type AdminSection = 'users' | 'server' | 'smtp';
type AdminSection = 'users' | 'organizations' | 'server' | 'smtp';
const SHARING_MODES: { id: SharingMode; title: string; text: string }[] = [
{
id: 'private',
title: '1. Privat (Keiner sieht andere)',
text: 'Jeder arbeitet für sich allein. Keine Benutzersuche, kein Teilen, keine Gruppen.',
},
{
id: 'organizations',
title: '2. Organisationen (Empfohlen)',
text: 'Benutzer sehen nur Mitglieder ihrer eigenen Organisation. Wer keiner Organisation angehört, sieht niemanden.',
},
{
id: 'open',
title: '3. Offen (Alle sehen alle)',
text: 'Alle Benutzer dieser Instanz können sich gegenseitig finden und miteinander teilen.',
},
];
// Stricter modes come first; switching to a stricter one can remove shares
const modeRank = (m: SharingMode) => SHARING_MODES.findIndex((x) => x.id === m);
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
currentUser,
@@ -44,9 +69,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
const [newUserOrg, setNewUserOrg] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [inviteOrg, setInviteOrg] = useState('');
// --- Organizations State ---
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [newOrgName, setNewOrgName] = useState('');
const [renamingOrgId, setRenamingOrgId] = useState<string | null>(null);
const [renameOrgVal, setRenameOrgVal] = useState('');
const [deletingOrgId, setDeletingOrgId] = useState<string | null>(null);
// Moving someone out of an organization removes their shares there, so it needs a confirmation
const [pendingMove, setPendingMove] = useState<{ user: User; orgId: string } | null>(null);
const [pendingMode, setPendingMode] = useState<{ mode: SharingMode; affected: number } | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
@@ -54,7 +91,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
// --- Server & SMTP Settings State ---
const [settings, setSettings] = useState<SystemSettings>({
sharingMode: 'teams_only',
sharingMode: 'organizations',
allowRegistration: false,
instanceName: 'MindSift',
smtpHost: '',
@@ -63,6 +100,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpPass: '',
smtpFrom: '',
smtpTls: true,
smtpSkipVerify: false,
smtpConfiguredViaEnv: false,
});
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
@@ -80,9 +118,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
setInvites(invList);
if (isServerAdmin) {
const sys = await api.adminGetSettings();
const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]);
setOrganizations(orgs);
setSettings({
sharingMode: sys.sharingMode || 'teams_only',
sharingMode: sys.sharingMode || 'organizations',
allowRegistration: Boolean(sys.allowRegistration),
instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '',
@@ -91,6 +130,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
});
}
@@ -116,8 +156,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
password: newUserPassword,
displayName: newUserName.trim() || newUserEmail.split('@')[0],
role: newUserRole,
organizationId: newUserOrg || undefined,
});
setUsers((prev) => [...prev, created]);
if (newUserOrg) await reloadOrganizations();
setNewUserName('');
setNewUserEmail('');
setNewUserPassword('');
@@ -136,6 +178,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const inv = await api.adminCreateInvite({
email: inviteEmail.trim(),
role: inviteRole,
organizationId: inviteOrg || undefined,
});
setInvites((prev) => [inv, ...prev]);
setInviteEmail('');
@@ -222,6 +265,86 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
// ============================================================================
// HANDLERS: SERVER & SMTP SETTINGS
// ============================================================================
// ============================================================================
// HANDLERS: ORGANIZATIONS
// ============================================================================
const reloadOrganizations = async () => {
try {
setOrganizations(await api.adminGetOrganizations());
} catch {
// keep the old list
}
};
const handleCreateOrganization = async (e: React.FormEvent) => {
e.preventDefault();
if (!newOrgName.trim()) return;
try {
await api.adminCreateOrganization(newOrgName.trim());
setNewOrgName('');
await reloadOrganizations();
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen der Organisation' });
}
};
const handleRenameOrganization = async (orgId: string) => {
try {
await api.adminRenameOrganization(orgId, renameOrgVal.trim());
setRenamingOrgId(null);
await reloadOrganizations();
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Umbenennen' });
}
};
const handleDeleteOrganization = async (orgId: string) => {
try {
await api.adminDeleteOrganization(orgId);
setDeletingOrgId(null);
setUsers((prev) => prev.map((u) => (u.organizationId === orgId ? { ...u, organizationId: '' } : u)));
await reloadOrganizations();
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen' });
}
};
const applyUserOrganization = async (u: User, orgId: string) => {
setPendingMove(null);
try {
await api.adminSetUserOrganization(u.id, orgId);
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, organizationId: orgId } : x)));
await reloadOrganizations();
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Zuordnen' });
}
};
const handleChangeUserOrganization = (u: User, orgId: string) => {
if (u.organizationId) setPendingMove({ user: u, orgId });
else applyUserOrganization(u, orgId);
};
const orgName = (id?: string) => organizations.find((o) => o.id === id)?.name || '';
// Tightening the mode first asks how much sharing would be lost
const handleSelectSharingMode = async (mode: SharingMode) => {
if (mode === settings.sharingMode) return;
if (modeRank(mode) < modeRank(settings.sharingMode)) {
try {
const affected = await api.adminSharingImpact(mode);
if (affected > 0) {
setPendingMode({ mode, affected });
return;
}
} catch {
// fall through and save
}
}
setPendingMode(null);
handleSaveSystemSettings({ sharingMode: mode });
};
const handleSaveSystemSettings = async (patch?: Partial<SystemSettings>) => {
setStatusMsg(null);
const next = { ...settings, ...(patch || {}) };
@@ -229,6 +352,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
try {
const saved = await api.adminUpdateSettings(next);
setSettings((prev) => ({ ...prev, ...saved }));
setCurrentSharingMode(saved.sharingMode);
setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
@@ -327,6 +451,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{isServerAdmin && (
<>
<button
onClick={() => setSection('organizations')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'organizations'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
>
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Organisationen</p>
<p className="text-[10px] text-faint">Wer wen sehen darf</p>
</div>
</button>
<button
onClick={() => setSection('server')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
@@ -383,7 +522,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</h3>
<form onSubmit={handleCreateUser} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">Anzeigename</label>
<input
@@ -432,6 +571,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<option value="admin">Server-Admin (Vollzugriff)</option>
</select>
</div>
{isServerAdmin && (
<div className="min-w-0">
<label className="block text-muted mb-1">Organisation</label>
<select
value={newUserOrg}
onChange={(e) => setNewUserOrg(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex justify-end">
@@ -454,7 +611,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="min-w-0">
<label className="block text-muted mb-1">
Gebundene E-Mail (optional)
@@ -479,6 +636,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<option value="admin">Server-Admin</option>
</select>
</div>
{isServerAdmin && (
<div className="min-w-0">
<label className="block text-muted mb-1">Organisation</label>
<select
value={inviteOrg}
onChange={(e) => setInviteOrg(e.target.value)}
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex justify-end">
@@ -511,6 +686,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<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]">
@@ -568,7 +744,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</span>
)}
</div>
<p className="text-muted text-[11px]">{u.email}</p>
<p className="text-muted text-[11px]">
{u.email}
{isServerAdmin && settings.sharingMode === 'organizations' && !u.organizationId && (
<span className="text-status-warn"> · ohne Organisation, sieht niemanden</span>
)}
</p>
</div>
</div>
@@ -585,6 +766,22 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<option value="admin">Server-Admin</option>
</select>
{isServerAdmin && (
<select
value={u.organizationId || ''}
onChange={(e) => handleChangeUserOrganization(u, e.target.value)}
className="h-7 px-2 input-field text-[11px] bg-panel max-w-[11rem]"
title="Organisation"
>
<option value="">Keine Organisation</option>
{organizations.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
)}
{isServerAdmin && (
<>
<button
@@ -619,6 +816,25 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
</div>
{pendingMove?.user.id === u.id && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-muted">
{u.displayName} verlässt „{orgName(u.organizationId)}“ und verliert dort alle Freigaben,
Gruppen und Aufgaben-Zuweisungen.
</span>
<button
onClick={() => applyUserOrganization(u, pendingMove.orgId)}
className="btn-primary h-7 px-3 text-[11px]"
>
{pendingMove.orgId ? `Nach „${orgName(pendingMove.orgId)}“ verschieben` : 'Entfernen'}
</button>
<button onClick={() => setPendingMove(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
{resetPwdUserId === u.id && (
<div className="flex items-center gap-2 pt-2 border-t border-border">
<input
@@ -651,6 +867,131 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* ================================================================= */}
{/* SECTION 2: ORGANISATIONEN (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'organizations' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Organisationen</h2>
<p className="text-muted mt-0.5">
Jeder Benutzer gehört höchstens einer Organisation an. Im Modus „Organisationen“ sehen sich nur
Mitglieder derselben Organisation und können nur untereinander teilen und Gruppen bilden.
Die Zuordnung änderst du in der Benutzerliste.
</p>
{settings.sharingMode !== 'organizations' && (
<p className="mt-2 text-status-warn">
Der aktuelle Sichtbarkeits-Modus ist nicht „Organisationen“; die Zuordnung wirkt sich derzeit nicht aus.
</p>
)}
</div>
<form onSubmit={handleCreateOrganization} className="flex gap-2 max-w-md">
<input
type="text"
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Name der neuen Organisation"
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" />
<span>Anlegen</span>
</button>
</form>
{organizations.length === 0 ? (
<p className="text-muted">Noch keine Organisation angelegt.</p>
) : (
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
{organizations.map((o) => {
const members = users.filter((u) => u.organizationId === o.id);
return (
<div key={o.id} className="p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{renamingOrgId === o.id ? (
<div className="flex flex-1 min-w-0 gap-2">
<input
type="text"
value={renameOrgVal}
onChange={(e) => setRenameOrgVal(e.target.value)}
className="flex-1 min-w-0 h-7 px-2 input-field"
autoFocus
/>
<button onClick={() => handleRenameOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
Speichern
</button>
<button onClick={() => setRenamingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<Building2 className="w-4 h-4 text-accent shrink-0" />
<span className="font-bold text-main truncate">{o.name}</span>
<span className="badge-count">{o.memberCount}</span>
</div>
)}
{renamingOrgId !== o.id && (
<div className="flex items-center gap-1.5">
<button
onClick={() => {
setRenamingOrgId(o.id);
setRenameOrgVal(o.name);
}}
className="btn-icon"
title="Umbenennen"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingOrgId(deletingOrgId === o.id ? null : o.id)}
className="btn-icon btn-icon-danger"
title="Organisation löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{members.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{members.map((m) => (
<span
key={m.id}
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-[11px] text-main"
>
<UserAvatar user={m} size="xxs" showTitle={false} />
<span>{m.displayName}</span>
</span>
))}
</div>
)}
{deletingOrgId === o.id && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-muted">
Die {o.memberCount} Mitglieder sind danach ohne Organisation und verlieren ihre
Freigaben, Gruppen und Zuweisungen untereinander.
</span>
<button onClick={() => handleDeleteOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
@@ -661,8 +1002,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert
arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind.
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
@@ -698,54 +1039,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'strict_private'
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">1. Strikt Privat (Keiner sieht andere)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
Benutzersuche möglich.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'teams_only'
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">2. Teams isoliert (Empfohlen)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
teilen.
</p>
</button>
<button
type="button"
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === 'open'
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">3. Offen (Alle sehen alle)</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
miteinander teilen.
</p>
</button>
{SHARING_MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => handleSelectSharingMode(m.id)}
className={`p-4 text-left border rounded-card transition-colors ${
settings.sharingMode === m.id
? 'bg-canvas border-accent text-main shadow-panel'
: 'bg-canvas/50 border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs text-main">{m.title}</p>
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
</button>
))}
</div>
{pendingMode && (
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
<span className="flex-1 min-w-0 text-main">
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
</span>
<button
onClick={() => {
const mode = pendingMode.mode;
setPendingMode(null);
handleSaveSystemSettings({ sharingMode: mode });
}}
className="btn-primary h-7 px-3 text-[11px]"
>
Trotzdem wechseln
</button>
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</div>
)}
</div>
{/* Public Registration Toggle Switch */}
@@ -762,25 +1094,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
</span>
<button
type="button"
role="switch"
aria-checked={settings.allowRegistration}
onClick={() =>
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
}`}
<ToggleSwitch
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
/>
</div>
</div>
</div>
@@ -886,20 +1204,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
/>
</div>
<div className="flex items-center pt-5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
<p className="text-[11px] text-muted">
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
</p>
</div>
<ToggleSwitch
checked={settings.smtpTls ?? true}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
}
className="w-4 h-4 checkbox-square cursor-pointer"
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
/>
<span className="text-main font-medium">
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
</span>
</label>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
<p className="text-[11px] text-muted">
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
</p>
</div>
<ToggleSwitch
checked={settings.smtpSkipVerify ?? false}
disabled={!(settings.smtpTls ?? true)}
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
/>
</div>
</div>
</div>
@@ -17,16 +17,22 @@ import {
Palette,
Upload,
Camera,
Pencil,
Copy,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { APIKey, User, UserTheme } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
interface UserSettingsViewProps {
currentUser: User;
activeTheme: ThemeId;
themes: ThemeOption[]; // built-in + custom
customThemes: UserTheme[];
onThemeChange: (themeId: ThemeId) => void;
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
@@ -36,12 +42,98 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
activeTheme,
themes,
customThemes,
onThemeChange,
onCustomThemesChange,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
// --- Custom Theme Editor State ---
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
const [themeName, setThemeName] = useState('');
const [themeCss, setThemeCss] = useState('');
const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingTheme, setSavingTheme] = useState(false);
const [deletingThemeId, setDeletingThemeId] = useState<string | null>(null);
const themeFileRef = useRef<HTMLInputElement>(null);
const resetThemeEditor = () => {
setThemeEditId(null);
setThemeName('');
setThemeCss('');
};
const handleThemeFile = async (file?: File) => {
if (!file) return;
const text = await file.text();
setThemeCss(text);
if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, ''));
setThemeMsg(null);
};
const handleDownloadTemplate = () => {
const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' }));
const a = document.createElement('a');
a.href = url;
a.download = 'theme-template.css';
a.click();
URL.revokeObjectURL(url);
};
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(themeTemplateCss);
setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' });
} catch {
setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' });
}
};
// Saving activates the theme right away (injected live, no reload)
const handleSaveTheme = async (e: React.FormEvent) => {
e.preventDefault();
setSavingTheme(true);
setThemeMsg(null);
try {
const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss });
const next = themeEditId
? customThemes.map((t) => (t.id === saved.id ? saved : t))
: [...customThemes, saved];
onCustomThemesChange(next, customThemeId(saved));
setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` });
resetThemeEditor();
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' });
} finally {
setSavingTheme(false);
}
};
const handleEditTheme = (t: UserTheme) => {
setThemeEditId(t.id);
setThemeName(t.name);
setThemeCss(t.css);
setThemeMsg(null);
};
const handleDeleteTheme = async (t: UserTheme) => {
try {
await api.deleteTheme(t.id);
setDeletingThemeId(null);
if (themeEditId === t.id) resetThemeEditor();
// Deleting the active theme falls back to the default one
onCustomThemesChange(
customThemes.filter((x) => x.id !== t.id),
activeTheme === customThemeId(t) ? 'dark' : undefined
);
} catch (err: any) {
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' });
}
};
// --- Profile State ---
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
const [email, setEmail] = useState(currentUser.email || '');
@@ -591,14 +683,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{AVAILABLE_THEMES.map((t) => {
{themes.map((t) => {
const isSelected = activeTheme === t.id;
const custom = customThemes.find((c) => customThemeId(c) === t.id);
return (
<div key={t.id} className="flex flex-col">
<button
key={t.id}
type="button"
onClick={() => onThemeChange(t.id)}
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
isSelected
? 'bg-canvas border-accent shadow-sm'
: 'bg-canvas border-border hover:border-strong'
@@ -654,23 +747,130 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<code className="text-[10px] font-mono text-faint">{t.file}</code>
</div>
</button>
{custom && (
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
{deletingThemeId === custom.id ? (
<>
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
Löschen
</button>
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
Abbrechen
</button>
</>
) : (
<>
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setDeletingThemeId(custom.id)}
className="btn-icon btn-icon-danger"
title="Theme löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
{/* Custom theme: upload or paste CSS, based on the template */}
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold text-main">
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
</p>
<div className="flex flex-wrap gap-1.5">
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Download className="w-3 h-3" />
<span>Vorlage herunterladen</span>
</button>
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
<Copy className="w-3 h-3" />
<span>Vorlage kopieren</span>
</button>
<button
type="button"
onClick={() => setThemeCss(themeTemplateCss)}
className="btn-action h-7 px-2 text-[11px]"
>
Vorlage einfügen
</button>
</div>
</div>
<p className="text-muted text-[11px] leading-relaxed">
Alle Theme-Dateien liegen unter{' '}
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
Kopiervorlage{' '}
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
Eigenschaften in 6 Blöcken gruppiert: <strong>1. Akzentfarbe</strong>,{' '}
<strong>2. Oberflächen</strong>, <strong>3. Textfarben</strong>,{' '}
<strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>.
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
<strong>3. Textfarben</strong>, <strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={themeName}
onChange={(e) => setThemeName(e.target.value)}
placeholder="Name, z. B. Wald"
maxLength={60}
className="flex-1 min-w-0 h-8 px-2.5 input-field"
/>
<button
type="button"
onClick={() => themeFileRef.current?.click()}
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
<span>CSS-Datei hochladen</span>
</button>
<input
ref={themeFileRef}
type="file"
accept=".css,text/css"
className="hidden"
onChange={(e) => {
handleThemeFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</div>
<textarea
value={themeCss}
onChange={(e) => setThemeCss(e.target.value)}
placeholder=":root[data-theme=&quot;mein-theme&quot;] { --accent-primary: #15803d; … }"
rows={10}
spellCheck={false}
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
/>
{themeMsg && (
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
{themeMsg.text}
</p>
)}
<div className="flex justify-end gap-2">
{themeEditId && (
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
Abbrechen
</button>
)}
<button
type="submit"
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
>
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
</button>
</div>
</form>
</div>
)}
+15 -3
View File
@@ -13,7 +13,7 @@ import {
LogOut,
} from 'lucide-react';
import { Team, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { api, isSharingDisabled, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UserAvatar } from '../../components/UserAvatar';
@@ -187,6 +187,18 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
!managedTeams.some((mt) => mt.id === t.id)
);
if (isSharingDisabled()) {
return (
<div className="w-full space-y-3">
<button onClick={onBack} className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer">
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<p className="text-xs text-muted">Gruppen sind auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).</p>
</div>
);
}
return (
<div className="w-full space-y-5">
{/* Page Header */}
@@ -203,13 +215,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
<div>
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
<Users className="w-4 h-4 text-accent" />
<span>Gruppen & Teams</span>
<span>Gruppen</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
{teams.length}
</span>
</h1>
<p className="text-xs text-muted mt-0.5">
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
Erstelle Gruppen (z. B. Familie, WG oder Projektteam) aus Personen, die du sehen kannst, und teile Notizen, Aufgaben und Listen mit allen auf einmal.
</p>
</div>
</div>
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
X,
Check,
@@ -16,8 +16,11 @@ import {
Folder,
Webhook,
Lock,
UserPlus,
} from 'lucide-react';
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
@@ -34,6 +37,8 @@ interface TaskDetailModalProps {
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
assignableMembers = [],
onUpdateAssignees,
onToggleSubtask,
onAddSubtask,
onDeleteSubtask,
@@ -71,6 +78,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
const [isEditingNotes, setIsEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(task.notes || '');
// The notes field grows with its text, so long notes scroll with the dialog instead of inside a small box
const growNotes = useCallback((el: HTMLTextAreaElement | null) => {
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight + 2}px`;
}, []);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState('');
@@ -172,7 +185,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
return (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
<div
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Top Header */}
@@ -272,7 +285,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
{/* Scrollable Body (all controls disabled for read-only shares) */}
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
{/* Task Title */}
<div>
{isEditingTitle ? (
@@ -517,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
</div>
</div>
{/* Assignees (shared lists only): chips + the shared type-to-search */}
{assignableMembers.length > 0 && (() => {
const assigned = (task.assignees || [])
.map((id) => assignableMembers.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m);
const assignedIds = assigned.map((m) => m.userId);
const candidates: User[] = assignableMembers.map((m) => ({
id: m.userId,
email: m.email,
displayName: m.displayName,
avatarUrl: m.avatarUrl,
role: 'member',
isActive: true,
createdAt: '',
}));
return (
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Zugewiesen:</span>
</label>
{assigned.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{assigned.map((m) => (
<span
key={m.userId}
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
>
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
<span>{m.displayName || m.email}</span>
{!readOnly && onUpdateAssignees && (
<button
type="button"
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
className="text-muted hover:text-status-err"
title="Zuweisung entfernen"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
) : (
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
)}
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
<UserAutocomplete
users={candidates}
selectedUser={null}
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
excludeUserIds={assignedIds}
placeholder="Person zuweisen (Name tippen)…"
/>
)}
</div>
);
})()}
{/* Hashtags Section */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
@@ -672,7 +746,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
rows={6}
autoFocus
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
ref={growNotes}
onInput={(e) => growNotes(e.currentTarget)}
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden"
/>
<div className="flex justify-end gap-1.5">
<button
@@ -725,6 +801,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
)}
</div>
</fieldset>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
+85 -35
View File
@@ -26,23 +26,14 @@ import {
Filter,
Webhook,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { useMobileActions } from '../../context/MobileActionContext';
const getInitials = (name?: string): string => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
};
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
@@ -134,16 +125,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
// happened to fall into the reload (press and release then hit different elements).
const loadedListRef = useRef<string | null>(null);
const loadSeqRef = useRef(0);
const loadData = async () => {
setLoading(true);
const seq = ++loadSeqRef.current;
const listId = selectedListId;
if (loadedListRef.current !== listId) setLoading(true);
try {
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
if (seq !== loadSeqRef.current) return; // a newer reload is under way
loadedListRef.current = listId;
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
setTeams(Array.isArray(allTeams) ? allTeams : []);
@@ -152,7 +152,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
setLoading(false);
if (seq === loadSeqRef.current) setLoading(false);
}
};
@@ -236,6 +236,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
const isTaskReadOnly = (task: Thought) =>
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
// assignees who lost access before the task payload caught up
const membersByList = useMemo(() => {
const m = new Map<string, TaskListMember[]>();
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
return m;
}, [safeTaskLists]);
const assignableMembers = (task: Thought): TaskListMember[] => {
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
return members.length > 1 ? members : [];
};
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
id: m.userId,
name: m.displayName || m.email,
avatarUrl: m.avatarUrl,
});
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
const members = assignableMembers(task);
return (task.assignees || [])
.map((id) => members.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m)
.map(memberToPerson);
};
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
const people = getAssigneePeople(task);
if (people.length === 0) return null;
return (
<AvatarStack
people={people}
size={size}
ringClass="ring-[var(--bg-panel)]"
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
/>
);
};
// Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => {
if (
@@ -464,6 +500,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
try {
const updated = await api.updateThought(task.id, { assignees });
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
}
};
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
await api.updateThought(task.id, { tags: newTags });
@@ -1274,6 +1321,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
</div>
{renderAssignees(task, 'xs')}
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<button
@@ -1333,37 +1382,33 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
{safeTaskLists.map((tl) => (
<div key={tl.id} className="relative group flex items-center">
<div
key={tl.id}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<button
onClick={() => setSelectedListId(tl.id)}
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
selectedListId === tl.id
? 'bg-panel border-accent text-main rounded-l-btn rounded-r-none border-r-0'
: 'bg-canvas border-border text-muted hover:text-main rounded-btn'
}`}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{tl.icon}</span>
<span>{tl.title}</span>
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
<span className="ml-1 badge-count">{tl.tasksCount}</span>
)}
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
) : (
getInitials(m.displayName || m.email)[0]
)}
</span>
))}
</div>
<AvatarStack
people={otherMembers.map(memberToPerson)}
size="xxs"
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
);
}
return (
@@ -1377,7 +1422,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<button
onClick={() => handleDeleteTaskList(tl.id)}
title="Liste löschen"
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
@@ -1403,7 +1448,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
? currentSelectedList.shares.length
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
@@ -2103,6 +2148,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
onUpdateAssignees={handleUpdateTaskAssignees}
onToggleSubtask={handleToggleSubtask}
onAddSubtask={handleAddSubtask}
onDeleteSubtask={handleDeleteSubtask}
@@ -2305,6 +2352,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
#{tag}
</button>
))}
{getAssigneePeople(task).length > 0 && (
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
)}
</div>
{/* Column Navigation controls: Move Left / Move Right */}
@@ -14,11 +14,16 @@ import {
Sparkles,
Plus,
Search,
Check,
CloudOff,
AlertTriangle,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
@@ -314,12 +319,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const extractedTags = useMemo(() => {
const tagSet = new Set<string>();
for (const b of blocks) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
tagSet.add(m.slice(1).toLowerCase());
}
}
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
}
return Array.from(tagSet);
}, [blocks]);
@@ -417,6 +417,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[engineState?.presence, currentUser?.id]
);
// Presence only carries names; profile pictures come from the user directory, loaded once
// the first time someone else shows up (avatars can be data URLs, too big for every sync)
const [avatarsById, setAvatarsById] = useState<Map<string, string>>(new Map());
const avatarsRequestedRef = useRef(false);
useEffect(() => {
if (otherPeople.length === 0 || avatarsRequestedRef.current) return;
avatarsRequestedRef.current = true;
api.getUsersDirectory()
.then((users) => {
const m = new Map<string, string>();
for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl);
setAvatarsById(m);
})
.catch(() => {});
}, [otherPeople.length]);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
const [notice, setNotice] = useState<string | null>(null);
const noticeTimerRef = useRef<number | null>(null);
@@ -755,6 +771,33 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
)}
{/* Save status on phones (the desktop shows it above the workspace) */}
{engineState && (
<span
className="sm:hidden inline-flex items-center shrink-0"
title={
engineState.status === 'saving'
? 'Speichert…'
: engineState.status === 'offline'
? 'Offline – Änderungen sind lokal gesichert'
: engineState.status === 'error'
? engineState.errorMessage || 'Fehler beim Speichern'
: 'Gespeichert'
}
aria-label="Speicherstatus"
>
{engineState.status === 'saving' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
) : engineState.status === 'offline' ? (
<CloudOff className="w-3.5 h-3.5 text-status-warn" />
) : engineState.status === 'error' ? (
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
) : (
<Check className="w-3.5 h-3.5 text-status-ok" />
)}
</span>
)}
{/* Read-only indicator */}
{!canEdit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
@@ -790,15 +833,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)}
{/* Who else has this note open */}
{otherPeople.length > 0 && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title="Hat diese Notiz gerade geöffnet"
>
<Users className="w-3 h-3 text-accent" />
{otherPeople.map((p) => p.name).join(', ')}
</span>
)}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
@@ -62,6 +62,7 @@ export interface OutlinerBlockProps {
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onFocusBlock?: (blockId: string) => void;
onBlurBlock?: (blockId: string) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
@@ -109,6 +110,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlock,
onNavigateFocus,
onFocusBlock,
onBlurBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
@@ -288,8 +290,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
useEffect(() => {
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
// measure again whenever its width changes
const el = textareaRef.current;
let lastWidth = el?.clientWidth ?? 0;
const observer =
el && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
if (el.clientWidth !== lastWidth) {
lastWidth = el.clientWidth;
adjustHeight();
}
})
: null;
if (el) observer?.observe(el);
return () => {
window.removeEventListener('resize', adjustHeight);
observer?.disconnect();
};
}, [adjustHeight, block.type]);
const filteredSlashCommands = useMemo(() => {
if (slashSearch === null) return [];
@@ -392,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleBlur = () => {
isFocusedRef.current = false;
setIsLocallyFocused(false);
onBlurBlock?.(block.id);
if (isPastingRef.current) {
isPastingRef.current = false;
return;
@@ -651,16 +671,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}
}}
>
{/* Indentation Guide Line */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
style={{ left: `${lvlIdx * 22 + 12}px` }}
/>
))}
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{hasChildren ? (
@@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Leaving a block (click into empty space) must drop its focus highlight; a focus move to
// another block has already set the new id, so only clear if it's still this one
const handleBlurBlock = useCallback((blockId: string) => {
setFocusedBlockId((cur) => (cur === blockId ? null : cur));
}, []);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
@@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onBlurBlock={handleBlurBlock}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
@@ -0,0 +1,41 @@
import React from 'react';
import { UserAvatar } from '../../components/UserAvatar';
import { AvatarStack } from '../../components/AvatarStack';
import { Presence } from '../../lib/docSync/transport';
const MAX_VISIBLE = 3;
interface PresenceAvatarsProps {
people: Presence[];
avatars: Map<string, string>; // userId -> avatarUrl
}
// Who else has the note open: the shared AvatarStack plus the full list on hover / keyboard focus
export const PresenceAvatars: React.FC<PresenceAvatarsProps> = ({ people, avatars }) => {
if (people.length === 0) return null;
return (
<div
className="group/presence relative flex items-center outline-none"
tabIndex={0}
aria-label={`Gerade online: ${people.map((p) => p.name).join(', ')}`}
>
<AvatarStack
people={people.map((p) => ({ id: p.userId, name: p.name, avatarUrl: avatars.get(p.userId) }))}
max={MAX_VISIBLE}
/>
<div className="invisible opacity-0 group-hover/presence:visible group-hover/presence:opacity-100 group-focus/presence:visible group-focus/presence:opacity-100 transition-opacity absolute right-0 top-full mt-1.5 z-30 min-w-[11rem] max-w-[16rem] p-1.5 rounded-card bg-panel border border-border shadow-lg">
<div className="px-1.5 pb-1 text-[10px] uppercase tracking-wide text-faint">Gerade online</div>
{people.map((p) => (
<div key={p.userId} className="flex items-center gap-2 px-1.5 py-1 text-xs text-main">
<span className="relative">
<UserAvatar name={p.name} avatarUrl={avatars.get(p.userId) || ''} size="xs" />
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-status-ok ring-2 ring-[var(--bg-panel)]" />
</span>
<span className="truncate">{p.name}</span>
</div>
))}
</div>
</div>
);
};
@@ -52,6 +52,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -289,12 +290,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const tagMap = new Map<string, number>();
for (const d of docs) {
for (const b of d.blocks || []) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
const clean = m.slice(1).toLowerCase();
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
}
for (const clean of extractHashtags(b.content)) {
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
}
}
}
@@ -1117,8 +1114,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="w-full space-y-3">
{/* Top Header: Workspace Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Workspace selector tabs & left sidebar toggle */}
<div className="flex flex-wrap items-center gap-1">
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
<button
type="button"
disabled={subView !== 'editor'}
@@ -1155,7 +1152,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button
type="button"
onClick={() => setSubView('editor')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'editor'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
@@ -1172,7 +1169,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSubView('todos');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'todos'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
@@ -1189,7 +1186,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSubView('tags');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'tags' || subView === 'tag_detail'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
@@ -1201,8 +1198,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button>
</div>
{/* Right Header Section: Save Status & Live Collaboration Button */}
<div className="flex items-center gap-2">
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? (
/* Save Status Pill */
<div
@@ -1272,7 +1269,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
Notizen & Outliner
</h2>
<div className="flex items-center gap-1">
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
<div className="hidden sm:flex items-center gap-1">
<button
type="button"
onClick={(e) => handleOpenNewFolder('', e)}
+16
View File
@@ -293,6 +293,22 @@ body {
font-weight: 500;
}
/* Round count bubble: same shape as the nav bar counters, but monochrome (tabs of task lists & lists) */
.badge-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 0.25rem;
border-radius: 9999px;
background-color: var(--text-muted);
color: var(--bg-panel);
font-size: 9px;
font-weight: 700;
line-height: 1;
}
.badge-ok {
display: inline-flex;
align-items: center;
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { extractHashtags } from '../hashtags';
describe('extractHashtags', () => {
it('finds tags at the start, after spaces and after "("', () => {
expect(extractHashtags('#start mitten #zwei (#drei)')).toEqual(['start', 'zwei', 'drei']);
});
it('ignores URL anchors and words like C#', () => {
expect(extractHashtags('example.com/blog#ueberschrift')).toEqual([]);
expect(extractHashtags('https://x.de/#anker und #echt, C# nicht')).toEqual(['echt']);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
describe('custom themes', () => {
beforeEach(() => {
localStorage.clear();
document.getElementById('mindsift-custom-theme')?.remove();
});
it('injects the CSS live and uses its data-theme', () => {
setActiveTheme('custom:thm_1', lightCss);
const style = document.getElementById('mindsift-custom-theme');
expect(style?.textContent).toBe(lightCss);
expect(document.documentElement.getAttribute('data-theme')).toBe('papier');
// light background -> no Tailwind dark class
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('ignores data-theme mentioned in comments (like in the template)', () => {
setActiveTheme('custom:thm_2', `/* benenne [data-theme="mein-theme"] um */\n:root[data-theme="wald"] { --bg-app: #0b1a10; }`);
expect(document.documentElement.getAttribute('data-theme')).toBe('wald');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('restores the active custom theme on boot from the cache', () => {
setActiveTheme('custom:thm_1', lightCss);
document.getElementById('mindsift-custom-theme')?.remove();
expect(initThemeOnBoot()).toBe('custom:thm_1');
expect(document.getElementById('mindsift-custom-theme')?.textContent).toBe(lightCss);
});
it('removes the custom CSS when switching back to a built-in theme', () => {
setActiveTheme('custom:thm_1', lightCss);
setActiveTheme('dark');
expect(document.getElementById('mindsift-custom-theme')).toBeNull();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(getActiveTheme()).toBe('dark');
});
it('falls back to dark when a custom theme has no CSS', () => {
setActiveTheme('custom:gone');
expect(getActiveTheme()).toBe('dark');
});
it('builds gallery previews from the CSS variables', () => {
const opt = customThemeOption({ id: 'thm_1', name: 'Papier', css: lightCss, createdAt: '', updatedAt: '' });
expect(opt.id).toBe('custom:thm_1');
expect(opt.previewBg).toBe('#fafafa');
expect(opt.previewAccent).toBe('#15803d');
expect(opt.isRounded).toBe(true);
});
});
+72 -39
View File
@@ -17,6 +17,8 @@ import {
SpaceMember,
SpaceShare,
SystemSettings,
SharingMode,
UserTheme,
Team,
TaskList,
TaskListShare,
@@ -28,6 +30,7 @@ import {
UserInvite,
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
@@ -123,10 +126,19 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return res.json();
}
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
let currentSharingMode: SharingMode = 'organizations';
export const setCurrentSharingMode = (mode: SharingMode) => {
currentSharingMode = mode;
};
export const isSharingDisabled = () => currentSharingMode === 'private';
export const api = {
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
async getAuthStatus(): Promise<AuthStatus> {
return request<AuthStatus>('/api/v1/auth/status');
const st = await request<AuthStatus>('/api/v1/auth/status');
if (st.sharingMode) setCurrentSharingMode(st.sharingMode);
return st;
},
async logout(): Promise<void> {
@@ -146,7 +158,7 @@ export const api = {
password: string;
displayName: string;
instanceName?: string;
sharingMode?: 'open' | 'teams_only' | 'strict_private';
sharingMode?: SharingMode;
allowRegistration?: boolean;
createSampleData?: boolean;
}): Promise<{ token: string; user: User }> {
@@ -254,7 +266,7 @@ export const api = {
id: `th_offline_${Date.now()}`,
userId: 'offline',
content,
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
tags: Array.from(new Set(extractHashtags(content))),
priority,
source,
status: 'inbox',
@@ -361,6 +373,7 @@ export const api = {
notes?: string;
subtasks?: SubTask[];
tags?: string[];
assignees?: string[];
}
): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
@@ -828,41 +841,7 @@ export const api = {
});
},
// --- ORGANIZATIONS & TEAMS ---
async getOrganizations(): Promise<Organization[]> {
const res = await request<Organization[]>('/api/v1/organizations');
return ensureArray(res).map((o) => ({
...o,
teams: ensureArray(o.teams).map((t) => ({
...t,
members: ensureArray(t.members),
})),
}));
},
async createOrganization(name: string): Promise<Organization> {
return request<Organization>('/api/v1/organizations', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async deleteOrganization(id: string): Promise<void> {
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
},
async getOrgTeams(orgId: string): Promise<Team[]> {
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
// --- GROUPS (user-managed) ---
async createTeam(name: string, description = ''): Promise<Team> {
return request<Team>('/api/v1/teams', {
method: 'POST',
@@ -922,6 +901,7 @@ export const api = {
password: string;
displayName: string;
role: string;
organizationId?: string;
}): Promise<User> {
return request<User>('/api/v1/admin/users', {
method: 'POST',
@@ -964,7 +944,7 @@ export const api = {
async adminCreateInvite(payload: {
email?: string;
role: string;
teamId?: string;
organizationId?: string;
}): Promise<UserInvite> {
return request<UserInvite>('/api/v1/admin/invites', {
method: 'POST',
@@ -987,6 +967,42 @@ export const api = {
});
},
// Counts shares and group memberships a switch to `mode` would remove
async adminSharingImpact(mode: SharingMode): Promise<number> {
const res = await request<{ affected: number }>(`/api/v1/admin/settings/sharing-impact?mode=${mode}`);
return res.affected || 0;
},
// --- ORGANIZATIONS (server admin only) ---
async adminGetOrganizations(): Promise<Organization[]> {
return ensureArray(await request<Organization[]>('/api/v1/admin/organizations'));
},
async adminCreateOrganization(name: string): Promise<Organization> {
return request<Organization>('/api/v1/admin/organizations', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async adminRenameOrganization(id: string, name: string): Promise<void> {
await request(`/api/v1/admin/organizations/${id}`, {
method: 'PUT',
body: JSON.stringify({ name }),
});
},
async adminDeleteOrganization(id: string): Promise<void> {
await request(`/api/v1/admin/organizations/${id}`, { method: 'DELETE' });
},
async adminSetUserOrganization(userId: string, organizationId: string): Promise<void> {
await request(`/api/v1/admin/users/${userId}/organization`, {
method: 'PUT',
body: JSON.stringify({ organizationId }),
});
},
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/admin/smtp/test', {
method: 'POST',
@@ -994,6 +1010,23 @@ export const api = {
});
},
// --- CUSTOM THEMES ---
async getThemes(): Promise<UserTheme[]> {
return ensureArray(await request<UserTheme[]>('/api/v1/themes'));
},
// Creates a theme, or updates it when `id` is given
async saveTheme(theme: { id?: string; name: string; css: string }): Promise<UserTheme> {
return request<UserTheme>('/api/v1/themes', {
method: 'POST',
body: JSON.stringify(theme),
});
},
async deleteTheme(id: string): Promise<void> {
await request(`/api/v1/themes/${id}`, { method: 'DELETE' });
},
// --- API KEYS, WEBHOOKS & EXPORT ---
async getAPIKeys(): Promise<APIKey[]> {
const res = await request<APIKey[]>('/api/v1/keys');
+9
View File
@@ -0,0 +1,9 @@
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
// and words like "C#" are not tags. Same rule as the server (domain.ExtractTags).
const HASHTAG = /(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
/** All hashtags in a text, lower case, in order of appearance (with duplicates). */
export function extractHashtags(text: string | undefined | null): string[] {
if (!text) return [];
return Array.from(text.matchAll(HASHTAG), (m) => m[1].toLowerCase());
}
+99 -28
View File
@@ -1,4 +1,8 @@
export type ThemeId = 'dark' | 'light' | 'high-contrast' | 'soft';
import type { UserTheme } from './types';
// Built-in themes are compiled CSS files (src/themes/); custom themes are user CSS from the server,
// injected at runtime and addressed as "custom:<id>"
export type ThemeId = string;
export interface ThemeOption {
id: ThemeId;
@@ -10,9 +14,14 @@ export interface ThemeOption {
previewAccent: string;
previewText: string;
isRounded: boolean;
isCustom?: boolean;
}
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
const CUSTOM_STYLE_ID = 'mindsift-custom-theme';
export const CUSTOM_THEME_PREFIX = 'custom:';
export const AVAILABLE_THEMES: ThemeOption[] = [
{
@@ -61,43 +70,105 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
},
];
export function getActiveTheme(): ThemeId {
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
// Comments are ignored when reading the CSS (the template mentions [data-theme="mein-theme"] in one)
const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
const readCssVar = (css: string, name: string): string | undefined =>
stripComments(css).match(new RegExp(`--${name}\\s*:\\s*([^;]+);`))?.[1].trim();
// The CSS decides its own [data-theme="…"]; without one it targets plain :root
const themeAttribute = (css: string): string =>
stripComments(css).match(/data-theme\s*=\s*["']?([\w-]+)/)?.[1] || 'custom';
// Light background -> no Tailwind dark class
function isLightColor(color?: string): boolean {
const hex = color?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (!hex) return false;
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
}
export function customThemeOption(t: UserTheme): ThemeOption {
const radius = readCssVar(t.css, 'radius-card');
return {
id: customThemeId(t),
name: t.name,
subtitle: 'Eigenes Theme',
file: 'Eigenes Theme (gespeichert auf dem Server)',
previewBg: readCssVar(t.css, 'bg-app') || '#121316',
previewPanel: readCssVar(t.css, 'bg-panel') || '#1a1c20',
previewAccent: readCssVar(t.css, 'accent-primary') || '#2563eb',
previewText: readCssVar(t.css, 'text-main') || '#f4f4f5',
isRounded: !!radius && !/^0(px)?$/.test(radius),
isCustom: true,
};
}
function injectCustomCss(css: string | null): void {
if (typeof document === 'undefined') return;
let el = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
if (css === null) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('style');
el.id = CUSTOM_STYLE_ID;
}
el.textContent = css;
// Last in <head>, so it wins over the compiled themes
document.head.appendChild(el);
}
function applyToDocument(themeId: ThemeId, customCss: string | null): void {
if (typeof document === 'undefined') return;
injectCustomCss(customCss);
const root = document.documentElement;
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
root.setAttribute('data-theme', attr);
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
root.classList.toggle('dark', !light);
}
function store(key: string, value: string | null): void {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
return saved;
}
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
// ignore
}
}
function load(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function getActiveTheme(): ThemeId {
const saved = load(THEME_STORAGE_KEY);
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
return 'dark';
}
export function setActiveTheme(themeId: ThemeId): void {
try {
localStorage.setItem(THEME_STORAGE_KEY, themeId);
} catch {
// ignore
}
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeId);
if (themeId !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// 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;
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
store(THEME_STORAGE_KEY, effectiveId);
store(CUSTOM_CSS_CACHE_KEY, css);
applyToDocument(effectiveId, css);
}
export function initThemeOnBoot(): ThemeId {
const active = getActiveTheme();
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', active);
if (active !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
return active;
}
+24 -6
View File
@@ -7,8 +7,12 @@ export interface AuthStatus {
allowRegistration: boolean;
instanceName: string;
demoMode?: boolean;
sharingMode?: SharingMode;
}
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
export type SharingMode = 'private' | 'organizations' | 'open';
export interface User {
id: string;
email: string;
@@ -22,6 +26,7 @@ export interface User {
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
organizationId?: string;
createdAt: string;
}
@@ -30,7 +35,8 @@ export interface UserInvite {
code: string;
email?: string;
role: UserRole;
teamId?: string;
organizationId?: string;
organizationName?: string;
createdBy: string;
usedBy?: string;
createdAt: string;
@@ -62,6 +68,7 @@ export interface Thought {
kanbanStatus?: string;
notes?: string;
subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list
alarmSent?: boolean;
sortOrder?: number;
createdAt: string;
@@ -100,8 +107,6 @@ export interface TaskList {
userId: string;
spaceId?: string;
spaceName?: string;
teamId?: string;
teamName?: string;
ownerId?: string;
ownerName?: string;
isOwner?: boolean;
@@ -117,11 +122,11 @@ export interface TaskList {
updatedAt: string;
}
// Defined by server admins; every user belongs to at most one
export interface Organization {
id: string;
name: string;
ownerId: string;
teams?: Team[];
memberCount: number;
createdAt: string;
}
@@ -145,7 +150,7 @@ export interface TeamMember {
}
export interface SystemSettings {
sharingMode: 'open' | 'teams_only' | 'strict_private';
sharingMode: SharingMode;
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
@@ -154,6 +159,7 @@ export interface SystemSettings {
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpSkipVerify?: boolean;
smtpConfiguredViaEnv?: boolean;
}
@@ -349,6 +355,8 @@ export interface ListItem {
isCompleted: boolean;
completedBy?: string;
completedByName?: string;
createdBy?: string;
createdByName?: string;
sortOrder: number;
createdAt: string;
completedAt?: string;
@@ -378,10 +386,20 @@ export interface SharedList {
listType: 'shopping' | 'checklist' | 'vacation';
items: ListItem[];
shares?: ListShare[];
members?: TaskListMember[]; // owner + everyone the list is shared with
updatedAt: string;
createdAt: string;
}
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
export interface UserTheme {
id: string;
name: string;
css: string;
createdAt: string;
updatedAt: string;
}
export interface APIKey {
id: string;
userId: string;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+189
View File
@@ -0,0 +1,189 @@
package api
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"os"
"strings"
"time"
)
// smtpSettings is the effective mail server configuration (admin settings, else environment).
type smtpSettings struct {
Host, Port, User, Pass, From string
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
SkipVerify bool // accept self-signed / mismatching certificates
}
// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO".
type smtpStepError struct {
Step string
Err error
}
func (e *smtpStepError) Error() string {
return fmt.Sprintf("SMTP-Schritt %s fehlgeschlagen: %v", e.Step, e.Err)
}
func (e *smtpStepError) Unwrap() error { return e.Err }
const smtpTimeout = 30 * time.Second
func (s *Server) smtpSettings() (smtpSettings, error) {
cfg := smtpSettings{Port: "587", TLS: true}
if sys, _ := s.store.GetSystemSettings(); sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
cfg = smtpSettings{
Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort),
User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom),
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
}
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
cfg = smtpSettings{
Host: strings.TrimSpace(s.cfg.SMTPHost), Port: strings.TrimSpace(s.cfg.SMTPPort),
User: strings.TrimSpace(s.cfg.SMTPUser), Pass: s.cfg.SMTPPass, From: strings.TrimSpace(s.cfg.SMTPFrom),
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
}
}
if cfg.Host == "" {
return cfg, fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if cfg.Port == "" {
cfg.Port = "587"
}
if cfg.From == "" {
if strings.Contains(cfg.User, "@") {
cfg.From = cfg.User
} else {
cfg.From = "noreply@mindsift.local"
}
}
return cfg, nil
}
// ehloName is how MindSift introduces itself (EHLO). It must name the sending side, never "localhost",
// which strict servers (Exchange/O365, Postfix with HELO checks) refuse: the sender's mail domain,
// else the machine's hostname.
func ehloName(from string) string {
if at := strings.LastIndex(from, "@"); at >= 0 {
if domain := strings.Trim(from[at+1:], " >"); strings.Contains(domain, ".") {
return domain
}
}
if h, err := os.Hostname(); err == nil && strings.Contains(h, ".") {
return h
}
return "mindsift.local"
}
// splitAddress turns "MindSift <alarm@firma.de>" into the bare address for the SMTP envelope
// (MAIL FROM / RCPT TO only accept that) and the header form with an encoded display name.
func splitAddress(raw string) (envelope, header string, err error) {
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
if err != nil {
return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw)
}
return addr.Address, addr.String(), nil
}
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte {
idBytes := make([]byte, 12)
_, _ = rand.Read(idBytes)
domain := ehloName(from)
var buf bytes.Buffer
fmt.Fprintf(&buf, "From: %s\r\n", from)
fmt.Fprintf(&buf, "To: %s\r\n", to)
fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close()
buf.WriteString("\r\n")
return buf.Bytes()
}
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses.
func sendMail(cfg smtpSettings, to, subject, body string) error {
fromAddr, fromHeader, err := splitAddress(cfg.From)
if err != nil {
return &smtpStepError{"Absender", err}
}
toAddr, toHeader, err := splitAddress(to)
if err != nil {
return &smtpStepError{"Empfänger", err}
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
tlsCfg := &tls.Config{ServerName: cfg.Host, InsecureSkipVerify: cfg.SkipVerify} //nolint:gosec // admin opt-in for self-signed certificates
dialer := &net.Dialer{Timeout: smtpTimeout}
implicitTLS := cfg.Port == "465" && cfg.TLS
var conn net.Conn
if implicitTLS {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
} else {
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return &smtpStepError{"Verbindung", err}
}
_ = conn.SetDeadline(time.Now().Add(2 * smtpTimeout))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
_ = conn.Close()
return &smtpStepError{"Begrüßung", err}
}
defer client.Close()
if err := client.Hello(ehloName(cfg.From)); err != nil {
return &smtpStepError{"EHLO", err}
}
if cfg.TLS && !implicitTLS {
if ok, _ := client.Extension("STARTTLS"); !ok {
return &smtpStepError{"STARTTLS", fmt.Errorf("der Server bietet kein STARTTLS an (TLS deaktivieren oder Port 465 nutzen)")}
}
if err := client.StartTLS(tlsCfg); err != nil {
return &smtpStepError{"STARTTLS", err}
}
}
if cfg.User != "" || cfg.Pass != "" {
if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil {
return &smtpStepError{"AUTH", err}
}
}
if err := client.Mail(fromAddr); err != nil {
return &smtpStepError{"MAIL FROM", err}
}
if err := client.Rcpt(toAddr); err != nil {
return &smtpStepError{"RCPT TO", err}
}
wc, err := client.Data()
if err != nil {
return &smtpStepError{"DATA", err}
}
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil {
_ = wc.Close()
return &smtpStepError{"DATA", err}
}
if err := wc.Close(); err != nil {
// The server answers after the final "." – content filters reject here
return &smtpStepError{"Nachricht annehmen", err}
}
_ = client.Quit()
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package api
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTP is a minimal ESMTP server with STARTTLS (self-signed) and AUTH PLAIN.
type fakeSMTP struct {
addr string
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
mu sync.Mutex
ehlo string
mailFrom string
rcptTo string
data string
}
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mail.intern"},
DNSNames: []string{"mail.intern"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go f.serve(conn, tlsCfg)
}
}()
return f
}
func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
defer conn.Close()
r, w := bufio.NewReader(conn), bufio.NewWriter(conn)
reply := func(s string) { _, _ = w.WriteString(s + "\r\n"); _ = w.Flush() }
reply("220 fake ESMTP")
secure := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimRight(line, "\r\n")
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "EHLO "):
f.mu.Lock()
f.ehlo = cmd[5:]
f.mu.Unlock()
if secure {
reply("250-fake\r\n250 AUTH PLAIN")
} else {
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
}
case upper == "STARTTLS":
reply("220 go ahead")
tlsConn := tls.Server(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil {
return
}
conn, secure = tlsConn, true
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
case strings.HasPrefix(upper, "AUTH "):
reply("235 ok")
case strings.HasPrefix(upper, "MAIL FROM"):
f.mu.Lock()
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
f.mu.Unlock()
reply("250 ok")
case strings.HasPrefix(upper, "RCPT TO"):
f.mu.Lock()
f.rcptTo = strings.TrimSpace(cmd[len("RCPT TO:"):])
f.mu.Unlock()
if f.rcptCode != "" {
reply(f.rcptCode)
} else {
reply("250 ok")
}
case upper == "DATA":
reply("354 go")
var sb strings.Builder
for {
l, err := r.ReadString('\n')
if err != nil {
return
}
if l == ".\r\n" {
break
}
sb.WriteString(l)
}
f.mu.Lock()
f.data = sb.String()
f.mu.Unlock()
reply("250 queued")
case upper == "QUIT":
reply("221 bye")
return
default:
reply("250 ok")
}
}
}
func (f *fakeSMTP) settings(skipVerify bool) smtpSettings {
host, port, _ := net.SplitHostPort(f.addr)
return smtpSettings{Host: host, Port: port, User: "alarm@firma.de", Pass: "geheim", From: "alarm@firma.de", TLS: true, SkipVerify: skipVerify}
}
func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
f := startFakeSMTP(t, "")
// The certificate is self-signed: refused unless verification is switched off
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo")
var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
}
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil {
t.Fatalf("send with SkipVerify failed: %v", err)
}
f.mu.Lock()
ehlo, data := f.ehlo, f.data
f.mu.Unlock()
if ehlo != "firma.de" {
t.Errorf("EHLO should name the sender domain, got %q", ehlo)
}
for _, header := range []string{"Date: ", "Message-ID: <", "Content-Transfer-Encoding: quoted-printable"} {
if !strings.Contains(data, header) {
t.Errorf("message lacks %q:\n%s", header, data)
}
}
if !strings.Contains(data, "Subject: =?utf-8?q?") {
t.Errorf("subject with emoji/umlauts must be RFC 2047 encoded:\n%s", data)
}
if strings.Contains(data, "fällig") {
t.Errorf("raw non-ASCII must not appear in the message:\n%s", data)
}
}
func TestSendMailReportsTheRefusingStep(t *testing.T) {
f := startFakeSMTP(t, "451 4.3.2 Internal server error")
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo")
var stepErr *smtpStepError
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
}
}
// The admin UI suggests "MindSift <alerts@domain>" as sender; the envelope must carry the bare address
func TestSendMailWithDisplayNameSender(t *testing.T) {
f := startFakeSMTP(t, "")
cfg := f.settings(true)
cfg.From = "MindSift Alarme <alarm@firma.de>"
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil {
t.Fatalf("send failed: %v", err)
}
f.mu.Lock()
defer f.mu.Unlock()
if f.mailFrom != "<alarm@firma.de>" || f.rcptTo != "<anna@example.com>" {
t.Errorf("envelope must hold bare addresses, got MAIL FROM %q, RCPT TO %q", f.mailFrom, f.rcptTo)
}
if !strings.Contains(f.data, `From: "MindSift Alarme" <alarm@firma.de>`) {
t.Errorf("header From should keep the display name:\n%s", f.data)
}
}
func TestEHLONameNeverLocalhost(t *testing.T) {
if got := ehloName("Alarm <alarm@mail.example.org>"); got != "mail.example.org" {
t.Errorf("got %q", got)
}
if got := ehloName("noreply"); got == "localhost" || got == "" {
t.Errorf("fallback must not be localhost, got %q", got)
}
}
+192 -150
View File
@@ -2,7 +2,6 @@ package api
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@@ -10,7 +9,6 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/smtp"
"strings"
"time"
@@ -110,6 +108,9 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport)
r.Get("/api/v1/themes", s.handleGetThemes)
r.Post("/api/v1/themes", s.handleSaveTheme)
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
// Instant Capture & Thoughts
r.Post("/api/v1/inbox", s.handleCaptureInbox)
@@ -191,12 +192,7 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// Organizations & Teams
r.Get("/api/v1/organizations", s.handleGetOrganizations)
r.Post("/api/v1/organizations", s.handleCreateOrganization)
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
// Groups (user-managed; stored as teams)
r.Get("/api/v1/teams", s.handleGetUserTeams)
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
@@ -210,7 +206,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/keys", s.handleCreateAPIKey)
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
// Staff Routes (Server-Admin & Team-Admin)
// Staff Routes (Server-Admin)
r.Group(func(r chi.Router) {
r.Use(s.staffMiddleware)
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
@@ -229,6 +225,14 @@ func (s *Server) Router() http.Handler {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
// Organizations decide who can see whom; only server admins manage them
r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations)
r.Post("/api/v1/admin/organizations", s.handleAdminCreateOrganization)
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
})
})
@@ -254,6 +258,29 @@ func (s *Server) Router() http.Handler {
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
if path == "index.html" {
if data, err := fs.ReadFile(s.staticFS, "index.html"); err == nil {
scheme := "http"
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := req.Header.Get("X-Forwarded-Host")
if host == "" {
host = req.Host
}
if host != "" {
origin := scheme + "://" + host
htmlStr := string(data)
htmlStr = strings.ReplaceAll(htmlStr, `content="/og-image.png"`, `content="`+origin+`/og-image.png"`)
htmlStr = strings.ReplaceAll(htmlStr, `property="og:url" content="/"`, `property="og:url" content="`+origin+`/"`)
data = []byte(htmlStr)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
return
}
}
fileServer.ServeHTTP(w, req)
})
}
@@ -276,7 +303,7 @@ func writeError(w http.ResponseWriter, status int, message string) {
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
if errors.Is(err, storage.ErrTaskListReadOnly) {
if errors.Is(err, storage.ErrTaskListReadOnly) || errors.Is(err, storage.ErrAssigneeNoAccess) {
writeError(w, http.StatusForbidden, err.Error())
return
}
@@ -430,6 +457,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
})
}
@@ -447,7 +475,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
InstanceName string `json:"instanceName"`
AllowRegistration bool `json:"allowRegistration"`
SharingMode string `json:"sharingMode"`
SeedSampleData bool `json:"seedSampleData"`
CreateSampleData bool `json:"createSampleData"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
@@ -479,13 +507,18 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
sys.SharingMode = req.SharingMode
}
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
_ = s.store.UpdateSystemSettings(sys)
// In organization mode the first admin starts in an organization named after the instance
if sys.SharingMode == storage.SharingOrganizations {
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
_ = s.store.SetUserOrganization(user.ID, org.ID)
user.OrganizationID = org.ID
}
}
}
if req.SeedSampleData {
if req.CreateSampleData {
_ = s.store.SeedStarterContent(user)
}
@@ -560,8 +593,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if validInvite != nil {
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
if validInvite.TeamID != "" {
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
if validInvite.OrganizationID != "" {
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
user.OrganizationID = validInvite.OrganizationID
}
}
@@ -891,6 +925,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -903,6 +938,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
writeStoreError(w, http.StatusBadRequest, err)
return
}
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
updated.Assignees = assignees
}
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
s.notifyDocChanged(docID, head)
@@ -1629,6 +1672,7 @@ func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Content string `json:"content"`
@@ -1640,7 +1684,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
return
}
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -2006,6 +2050,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
})
}
// --- CUSTOM THEMES ---
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, themes)
}
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
}
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
return
}
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status := http.StatusCreated
if req.ID != "" {
status = http.StatusOK
}
writeJSON(w, status, theme)
}
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
exportData, err := s.store.ExportUserData(userID)
@@ -2211,7 +2298,6 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
@@ -2220,7 +2306,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
@@ -2321,9 +2407,8 @@ func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string,
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgs, err := s.store.GetOrganizationsForUser(userID)
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
orgs, err := s.store.GetOrganizations()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -2331,8 +2416,7 @@ func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
@@ -2340,7 +2424,7 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
org, err := s.store.CreateOrganization(userID, req.Name)
org, err := s.store.CreateOrganization(req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -2348,45 +2432,52 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
if err := s.store.DeleteOrganization(orgID); err != nil {
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
teams, err := s.store.GetTeamsForOrg(orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgID := chi.URLParam(r, "id")
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
if err != nil {
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
}
func (s *Server) isTeamAdmin(userID, teamID string) bool {
@@ -2405,20 +2496,25 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request)
var req struct {
Name string `json:"name"`
Description string `json:"description"`
OrgID string `json:"orgId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
if s.store.SharingMode() == storage.SharingPrivate {
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
return
}
orgID := strings.TrimSpace(req.OrgID)
if orgID == "" {
orgID = "default"
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
return
}
// A group belongs to its creator's organization; members can only come from there
creator, _ := s.store.GetUserByID(userID)
orgID := ""
if creator != nil {
orgID = creator.OrganizationID
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
@@ -2501,17 +2597,19 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
}
targetUserID := strings.TrimSpace(req.UserID)
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
u, err := s.store.GetUserByEmail(req.Email)
if err != nil || u == nil {
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
return
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
targetUserID = u.ID
}
targetUserID = u.ID
}
if targetUserID == "" {
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
// Only people the group admin can see; unknown and invisible look the same
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
role = "member"
@@ -2588,10 +2686,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -2619,6 +2718,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.OrganizationID != "" {
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
u.OrganizationID = req.OrganizationID
}
}
writeJSON(w, http.StatusCreated, u)
}
@@ -2727,10 +2831,10 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Role string `json:"role"`
TeamID string `json:"teamId"`
ValidDays int `json:"validDays"`
Email string `json:"email"`
Role string `json:"role"`
OrganizationID string `json:"organizationId"`
ValidDays int `json:"validDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
@@ -2739,7 +2843,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.TeamID, req.ValidDays)
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -2774,10 +2878,15 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
if settings.SharingMode != previousMode {
_, _ = s.store.PruneSharing(settings.SharingMode, false)
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
@@ -2812,85 +2921,19 @@ func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
sys, _ := s.store.GetSystemSettings()
host := ""
port := "587"
user := ""
pass := ""
from := ""
useTLS := true
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
host = strings.TrimSpace(sys.SMTPHost)
port = strings.TrimSpace(sys.SMTPPort)
user = strings.TrimSpace(sys.SMTPUser)
pass = sys.SMTPPass
from = strings.TrimSpace(sys.SMTPFrom)
useTLS = sys.SMTPTLS
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
host = strings.TrimSpace(s.cfg.SMTPHost)
port = strings.TrimSpace(s.cfg.SMTPPort)
user = strings.TrimSpace(s.cfg.SMTPUser)
pass = s.cfg.SMTPPass
from = strings.TrimSpace(s.cfg.SMTPFrom)
useTLS = s.cfg.SMTPTLS
}
if host == "" {
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if port == "" {
port = "587"
}
if from == "" {
if user != "" && strings.Contains(user, "@") {
from = user
} else {
from = "noreply@mindsift.local"
}
}
addr := fmt.Sprintf("%s:%s", host, port)
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
var smtpAuth smtp.Auth
if user != "" || pass != "" {
smtpAuth = smtp.PlainAuth("", user, pass, host)
}
if port == "465" && useTLS {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Quit()
if smtpAuth != nil {
if err := client.Auth(smtpAuth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(toEmail); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
_, err = wc.Write([]byte(msg))
_ = wc.Close()
cfg, err := s.smtpSettings()
if err != nil {
return err
}
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
step := ""
if se, ok := err.(*smtpStepError); ok {
step = se.Step
}
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
return err
}
return nil
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
@@ -3004,4 +3047,3 @@ func (s *Server) startAlarmTicker() {
}
}
}
+59 -7
View File
@@ -24,6 +24,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
@@ -57,6 +58,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to create fresh store: %v", err)
}
@@ -95,8 +97,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
"displayName": "Server Owner",
"instanceName": "My MindSift",
"allowRegistration": false,
"sharingMode": "teams_only",
"seedSampleData": false
"sharingMode": "organizations",
"createSampleData": false
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
@@ -118,7 +120,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
arrayEndpoints := []string{
"/api/v1/task-lists",
"/api/v1/tasks",
"/api/v1/organizations",
"/api/v1/admin/organizations",
"/api/v1/teams",
"/api/v1/lists",
}
@@ -436,6 +438,7 @@ func TestNewFullFeatures(t *testing.T) {
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
@@ -523,6 +526,7 @@ func TestLoginWithUsernameAndDemoMode(t *testing.T) {
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
@@ -626,6 +630,8 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to create team: %v", err)
}
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
// Sharing with a group requires being in it
_ = store.AddTeamMember(team.ID, adminUser.ID, "admin")
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
@@ -754,6 +760,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_webhook.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
@@ -773,7 +780,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
"displayName": "Home User",
"instanceName": "MindSift Home",
"allowRegistration": false,
"seedSampleData": false
"createSampleData": false
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
@@ -845,6 +852,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
func TestSSEGlobalEndpoint(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
@@ -864,7 +872,7 @@ func TestSSEGlobalEndpoint(t *testing.T) {
"displayName": "SSE User",
"instanceName": "MindSift SSE",
"allowRegistration": false,
"seedSampleData": false
"createSampleData": false
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
@@ -1309,8 +1317,52 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
}
}
// openSharing lets tests that are not about visibility share freely between their users.
func openSharing(t *testing.T, store *storage.Store) {
t.Helper()
if store == nil {
return
}
sys, err := store.GetSystemSettings()
if err != nil {
t.Fatalf("GetSystemSettings: %v", err)
}
sys.SharingMode = storage.SharingOpen
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatalf("UpdateSystemSettings: %v", err)
}
}
// The setup switch "Beispieldaten anlegen" fills the new admin's account, but creates no other users
// (shared demo accounts exist only in demo mode).
func TestFirstBootSetupWithSampleData(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "sample.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil)
body := []byte(`{"email":"me@example.com","password":"secret","displayName":"Ich","createSampleData":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("setup: %d %s", rec.Code, rec.Body.String())
}
var res struct {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &res)
if n, _ := store.CountUsers(); n != 1 {
t.Fatalf("sample data must not create users, found %d", n)
}
docs, _ := store.GetDocuments(res.User.ID)
lists, _ := store.GetTaskLists(res.User.ID)
if len(docs) == 0 || len(lists) == 0 {
t.Fatalf("expected sample notes and task lists, got %d notes, %d task lists", len(docs), len(lists))
}
}
+4
View File
@@ -25,6 +25,8 @@ type Config struct {
SMTPPass string
SMTPFrom string
SMTPTLS bool
// SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers)
SMTPSkipVerify bool
}
func Load() *Config {
@@ -60,6 +62,7 @@ func Load() *Config {
smtpPass := getEnv("SMTP_PASS", "")
smtpFrom := getEnv("SMTP_FROM", "")
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
return &Config{
Port: port,
@@ -77,6 +80,7 @@ func Load() *Config {
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
SMTPSkipVerify: smtpSkipVerify,
}
}
+69 -32
View File
@@ -21,7 +21,31 @@ func NowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
var hashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
// and words like "C#" are not tags. The browser uses the same rule (lib/markdown.tsx, lib/api.ts).
var hashtagRegex = regexp.MustCompile(`(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
// legacyHashtagRegex is the rule before anchors were excluded; only used to clean up stored tags.
var legacyHashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
// StaleAnchorTags returns tags the old rule found in content that the current rule does not, i.e. tags that
// only came from URL fragments and similar.
func StaleAnchorTags(content string) []string {
current := map[string]bool{}
for _, t := range ExtractTags(content) {
current[t] = true
}
var stale []string
seen := map[string]bool{}
for _, m := range legacyHashtagRegex.FindAllStringSubmatch(content, -1) {
t := strings.ToLower(m[1])
if !current[t] && !seen[t] {
seen[t] = true
stale = append(stale, t)
}
}
return stale
}
// ExtractTags extracts #hashtags from content
func ExtractTags(content string) []string {
@@ -78,7 +102,7 @@ func FormatGermanDateTime(isoStr string) string {
time.May: "Mai",
time.June: "Juni",
time.July: "Juli",
time.August: "Aug.",
time.August: "Aug.",
time.September: "Sept.",
time.October: "Okt.",
time.November: "Nov.",
@@ -107,20 +131,21 @@ type User struct {
DiscordWebhookURL string `json:"discordWebhookUrl"`
CustomWebhookURL string `json:"customWebhookUrl"`
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
OrganizationID string `json:"organizationId,omitempty"`
CreatedAt string `json:"createdAt"`
}
type UserInvite struct {
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | admin
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
CreatedBy string `json:"createdBy"`
ExpiresAt string `json:"expiresAt"`
UsedBy string `json:"usedBy,omitempty"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | admin
OrganizationID string `json:"organizationId,omitempty"`
OrganizationName string `json:"organizationName,omitempty"`
CreatedBy string `json:"createdBy"`
ExpiresAt string `json:"expiresAt"`
UsedBy string `json:"usedBy,omitempty"`
CreatedAt string `json:"createdAt"`
}
type ThoughtStatus string
@@ -157,6 +182,7 @@ type Thought struct {
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
AlarmSent bool `json:"alarmSent,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
@@ -208,8 +234,6 @@ type TaskList struct {
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
SpaceName string `json:"spaceName,omitempty"`
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Permission string `json:"permission,omitempty"` // owner | editor | viewer
@@ -224,12 +248,12 @@ type TaskList struct {
UpdatedAt string `json:"updatedAt"`
}
// Organization is defined by server admins; each user belongs to at most one.
type Organization struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"ownerId"`
Teams []Team `json:"teams,omitempty"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
Name string `json:"name"`
MemberCount int `json:"memberCount"`
CreatedAt string `json:"createdAt"`
}
type Team struct {
@@ -252,7 +276,7 @@ type TeamMember struct {
}
type SystemSettings struct {
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
SharingMode string `json:"sharingMode"` // private | organizations | open
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
@@ -261,6 +285,7 @@ type SystemSettings struct {
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
}
@@ -402,24 +427,36 @@ type ListItem struct {
IsCompleted bool `json:"isCompleted"`
CompletedBy string `json:"completedBy,omitempty"`
CompletedByName string `json:"completedByName,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedByName string `json:"createdByName,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt,omitempty"`
}
type SharedList struct {
ID string `json:"id"`
UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
UserID string `json:"userId"`
OwnerName string `json:"ownerName,omitempty"`
IsOwner bool `json:"isOwner"`
Role string `json:"role,omitempty"` // editor | viewer
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"`
Shares []ListShare `json:"shares,omitempty"`
Members []TaskListMember `json:"members,omitempty"` // owner + everyone the list is shared with
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
}
// UserTheme is a custom CSS theme of one user.
type UserTheme struct {
ID string `json:"id"`
Name string `json:"name"`
CSS string `json:"css"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type APIKey struct {
+34
View File
@@ -0,0 +1,34 @@
package domain
import (
"reflect"
"testing"
)
func TestExtractTagsIgnoresURLAnchors(t *testing.T) {
cases := map[string][]string{
"#start mitten #zwei": {"start", "zwei"},
"siehe example.com/blog#ueberschrift": {},
"https://example.com/blog#ueberschrift und #echt": {"echt"},
"[Link](https://x.de/#anker) #tag": {"tag"},
"(#inKlammern) C# ist kein Tag": {"inklammern"},
"Zeile eins\n#zeilenanfang": {"zeilenanfang"},
"#Doppelt #doppelt": {"doppelt"},
}
for in, want := range cases {
got := ExtractTags(in)
if len(got) == 0 && len(want) == 0 {
continue
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ExtractTags(%q) = %v, want %v", in, got, want)
}
}
}
func TestStaleAnchorTags(t *testing.T) {
got := StaleAnchorTags("example.com/a#teil und #teil sowie x.de/#nur")
if !reflect.DeepEqual(got, []string{"nur"}) {
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
}
}
+20
View File
@@ -13,6 +13,7 @@ import (
func newSyncTestStore(t *testing.T) (*Store, *domain.User) {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
openSharing(t, store)
if err != nil {
t.Fatalf("store: %v", err)
}
@@ -406,3 +407,22 @@ func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
t.Fatalf("restart seeded again: %d notes", docs)
}
}
func TestAnchorTagMigrationKeepsRealTags(t *testing.T) {
s, u := newSyncTestStore(t)
th, err := s.CreateThought(u.ID, "Lesen: example.com/blog#ueberschrift #lesen", "normal", "manual", "")
if err != nil {
t.Fatal(err)
}
if len(th.Tags) != 1 || th.Tags[0] != "lesen" {
t.Fatalf("new thoughts must not get anchor tags: %v", th.Tags)
}
// State before the rule change: anchor stored as tag, plus a tag added by hand
s.db.Exec(`UPDATE thoughts SET tags = '["ueberschrift","lesen","manuell"]' WHERE id = ?`, th.ID)
s.db.Exec(`DELETE FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey)
s.migrateAnchorTags()
got, _ := s.GetThoughtByID(u.ID, th.ID)
if len(got.Tags) != 2 || got.Tags[0] != "lesen" || got.Tags[1] != "manuell" {
t.Fatalf("only the anchor tag must go: %v", got.Tags)
}
}
+2 -2
View File
@@ -119,7 +119,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
}
for i, it := range items {
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i)
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i, user.ID)
if err == nil && (i == 1 || i == 6 || i == 8) {
_, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
}
@@ -132,7 +132,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
{"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"},
{"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "Ausrüstung"},
} {
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i)
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i, user.ID)
}
}
+361 -317
View File
File diff suppressed because it is too large. Load diff
+26 -5
View File
@@ -15,6 +15,7 @@ func TestStoreLifecycle(t *testing.T) {
dbPath := filepath.Join(tmpDir, "test_mindsift.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -54,7 +55,7 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil {
t.Fatalf("CreateList failed: %v", err)
}
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0)
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0, user.ID)
if err != nil {
t.Fatalf("CreateListItem failed: %v", err)
}
@@ -106,7 +107,7 @@ func TestStoreLifecycle(t *testing.T) {
}
// 6. Test Organizations & Teams
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
org, err := store.CreateOrganization("Mindsift Inc")
if err != nil || org.ID == "" {
t.Fatalf("CreateOrganization failed: %v", err)
}
@@ -138,7 +139,7 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil || sysSettings.InstanceName == "" {
t.Fatalf("GetSystemSettings failed: %v", err)
}
sysSettings.SharingMode = "teams_only"
sysSettings.SharingMode = SharingOpen
err = store.UpdateSystemSettings(sysSettings)
if err != nil {
t.Fatalf("UpdateSystemSettings failed: %v", err)
@@ -179,7 +180,7 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("viewer must not delete tasks, got %v", err)
}
newTitle := "Umbenannt"
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not update task list, got %v", err)
}
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
@@ -236,6 +237,7 @@ func TestPerUserDocPinning(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pin_test.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -312,6 +314,7 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
dbPath := filepath.Join(tmpDir, "demo_test.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -368,6 +371,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
dbPath := filepath.Join(tmpDir, "folder_test.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -441,6 +445,7 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
dbPath := filepath.Join(tmpDir, "tags_test.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -542,6 +547,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
dbPath := filepath.Join(tmpDir, "test_db_first.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -692,6 +698,7 @@ func TestBlockListAndDerivedData(t *testing.T) {
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
store, err := NewStore(dbPath)
openSharing(t, store)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
@@ -752,4 +759,18 @@ func TestBlockListAndDerivedData(t *testing.T) {
}
}
// openSharing lets tests that are not about visibility share freely between their users.
func openSharing(t *testing.T, store *Store) {
t.Helper()
if store == nil {
return
}
sys, err := store.GetSystemSettings()
if err != nil {
t.Fatalf("GetSystemSettings: %v", err)
}
sys.SharingMode = SharingOpen
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatalf("UpdateSystemSettings: %v", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package storage
import (
"encoding/json"
"log/slog"
"github.com/sandrodach/mindsift/internal/domain"
)
const tagsAnchorMigrationKey = "migration_tags_without_anchors"
// migrateAnchorTags removes tags that only came from URL fragments (example.com/blog#ueberschrift) since the
// hashtag rule now requires whitespace or "(" before "#". Runs once. Only such tags are removed from
// thoughts and notes, so tags users added by hand stay; block_tags is rebuilt from the blocks.
func (s *Store) migrateAnchorTags() {
var done string
if s.db.QueryRow(`SELECT value FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey).Scan(&done) == nil {
return
}
cleaned := 0
for _, table := range []struct{ name, text string }{{"thoughts", "content"}, {"documents", "content"}} {
rows, err := s.db.Query(`SELECT id, ` + table.text + `, tags FROM ` + table.name + ` WHERE ` + table.text + ` LIKE '%#%'`)
if err != nil {
slog.Error("Bereinigung der Hashtags fehlgeschlagen", "table", table.name, "error", err)
return
}
type row struct{ id, text, tags string }
var list []row
for rows.Next() {
var r row
if rows.Scan(&r.id, &r.text, &r.tags) == nil {
list = append(list, r)
}
}
rows.Close()
for _, r := range list {
stale := domain.StaleAnchorTags(r.text)
if len(stale) == 0 {
continue
}
drop := map[string]bool{}
for _, t := range stale {
drop[t] = true
}
var tags []string
if json.Unmarshal([]byte(r.tags), &tags) != nil {
continue
}
kept := make([]string, 0, len(tags))
for _, t := range tags {
if !drop[t] {
kept = append(kept, t)
}
}
if len(kept) != len(tags) {
out, _ := json.Marshal(kept)
if _, err := s.db.Exec(`UPDATE `+table.name+` SET tags = ? WHERE id = ?`, string(out), r.id); err == nil {
cleaned++
}
}
}
}
if err := s.reindexAllDocBlockTags(); err != nil {
slog.Error("Neuaufbau der Block-Hashtags fehlgeschlagen", "error", err)
return
}
_, _ = s.db.Exec(`INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, 'done')`, tagsAnchorMigrationKey)
if cleaned > 0 {
slog.Info("Hashtags aus URL-Ankern entfernt", "entries", cleaned)
}
}
+122
View File
@@ -0,0 +1,122 @@
package storage
import (
"errors"
"path/filepath"
"testing"
)
func TestTaskAssigneesFollowListAccess(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "assignees.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
owner, _ := store.EnsureDefaultUser()
direct, _ := store.CreateUser("direct@example.com", "hash", "Direkt", "member")
viaTeam, _ := store.CreateUser("team@example.com", "hash", "Team", "member")
stranger, _ := store.CreateUser("stranger@example.com", "hash", "Fremd", "member")
tl, err := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "")
if err != nil {
t.Fatalf("CreateTaskList failed: %v", err)
}
task, err := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", tl.ID, "todo", "")
if err != nil {
t.Fatalf("CreateThoughtWithDetails failed: %v", err)
}
if _, err := store.UpdateThoughtTriage(owner.ID, task.ID, "keep", "task", "", "", tl.ID); err != nil {
t.Fatalf("UpdateThoughtTriage failed: %v", err)
}
// Everyone but the stranger shares the organization, so they can see each other
org, _ := store.CreateOrganization("Familie")
for _, u := range []string{owner.ID, direct.ID, viaTeam.ID} {
if err := store.SetUserOrganization(u, org.ID); err != nil {
t.Fatalf("SetUserOrganization failed: %v", err)
}
}
team, err := store.CreateTeam(org.ID, "WG", "")
if err != nil {
t.Fatalf("CreateTeam failed: %v", err)
}
_ = store.AddTeamMember(team.ID, owner.ID, "admin")
_ = store.AddTeamMember(team.ID, viaTeam.ID, "member")
if _, err := store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID); err != nil {
t.Fatalf("AddTaskListShare user failed: %v", err)
}
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "viewer", owner.ID); err != nil {
t.Fatalf("AddTaskListShare team failed: %v", err)
}
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{stranger.ID}); !errors.Is(err, ErrAssigneeNoAccess) {
t.Fatalf("assigning someone without access must fail, got %v", err)
}
if _, err := store.SetTaskAssignees(viaTeam.ID, task.ID, []string{viaTeam.ID}); !errors.Is(err, ErrTaskListReadOnly) {
t.Fatalf("viewer must not assign, got %v", err)
}
got, err := store.SetTaskAssignees(direct.ID, task.ID, []string{owner.ID, direct.ID, viaTeam.ID, direct.ID})
if err != nil || len(got) != 3 {
t.Fatalf("expected 3 assignees, got %v (%v)", got, err)
}
tasks, _ := store.GetTasks(owner.ID, tl.ID)
if len(tasks) != 1 || len(tasks[0].Assignees) != 3 {
t.Fatalf("GetTasks should carry assignees, got %+v", tasks)
}
// Leaving the team removes the team member's assignment
if err := store.RemoveTeamMember(team.ID, viaTeam.ID); err != nil {
t.Fatalf("RemoveTeamMember failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
// Removing the direct share removes that user's assignment, the owner stays
if err := store.RemoveTaskListShare(tl.ID, direct.ID); err != nil {
t.Fatalf("RemoveTaskListShare failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
// Access through a second path keeps the assignment
_ = store.AddTeamMember(team.ID, direct.ID, "member")
_, _ = store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID)
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{owner.ID, direct.ID}); err != nil {
t.Fatalf("re-assign failed: %v", err)
}
_ = store.RemoveTaskListShare(tl.ID, direct.ID)
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
// Deleting the team takes the last path away
if err := store.DeleteTeam(team.ID); err != nil {
t.Fatalf("DeleteTeam failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
// Moving the task out of the list drops all assignments
empty := ""
if _, err := store.UpdateThoughtDetails(owner.ID, task.ID, nil, nil, nil, nil, &empty, nil, nil, nil, nil); err != nil {
t.Fatalf("UpdateThoughtDetails failed: %v", err)
}
assertAssignees(t, store, owner.ID, task.ID)
}
func assertAssignees(t *testing.T, store *Store, viewerID, taskID string, want ...string) {
t.Helper()
th, err := store.GetThoughtByID(viewerID, taskID)
if err != nil {
t.Fatalf("GetThoughtByID failed: %v", err)
}
if len(th.Assignees) != len(want) {
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
}
have := map[string]bool{}
for _, id := range th.Assignees {
have[id] = true
}
for _, id := range want {
if !have[id] {
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package storage
import (
"errors"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// MaxThemeCSSBytes limits a custom theme; the shipped themes are around 3 KB.
const MaxThemeCSSBytes = 200 * 1024
// GetUserThemes returns the user's own CSS themes, oldest first.
func (s *Store) GetUserThemes(userID string) ([]domain.UserTheme, error) {
rows, err := s.db.Query(
`SELECT id, name, css_content, created_at, updated_at FROM user_themes WHERE user_id = ? ORDER BY created_at ASC`,
userID,
)
if err != nil {
return []domain.UserTheme{}, err
}
defer rows.Close()
themes := make([]domain.UserTheme, 0)
for rows.Next() {
var t domain.UserTheme
if rows.Scan(&t.ID, &t.Name, &t.CSS, &t.CreatedAt, &t.UpdatedAt) == nil {
themes = append(themes, t)
}
}
return themes, nil
}
// SaveUserTheme creates a theme (empty themeID) or updates one of the user's themes.
func (s *Store) SaveUserTheme(userID, themeID, name, css string) (*domain.UserTheme, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name des Themes ist erforderlich")
}
if len([]rune(name)) > 60 {
return nil, errors.New("Name des Themes ist zu lang (max. 60 Zeichen)")
}
if strings.TrimSpace(css) == "" {
return nil, errors.New("Das Theme enthält kein CSS")
}
if len(css) > MaxThemeCSSBytes {
return nil, errors.New("Das Theme ist zu groß (max. 200 KB)")
}
now := domain.NowISO()
if themeID == "" {
t := &domain.UserTheme{ID: domain.NewID("thm"), Name: name, CSS: css, CreatedAt: now, UpdatedAt: now}
if _, err := s.db.Exec(
`INSERT INTO user_themes (id, user_id, name, css_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
t.ID, userID, t.Name, t.CSS, t.CreatedAt, t.UpdatedAt,
); err != nil {
return nil, err
}
return t, nil
}
res, err := s.db.Exec(
`UPDATE user_themes SET name = ?, css_content = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
name, css, now, themeID, userID,
)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, errors.New("Theme nicht gefunden")
}
t := &domain.UserTheme{ID: themeID, Name: name, CSS: css, UpdatedAt: now}
_ = s.db.QueryRow(`SELECT created_at FROM user_themes WHERE id = ?`, themeID).Scan(&t.CreatedAt)
return t, nil
}
func (s *Store) DeleteUserTheme(userID, themeID string) error {
res, err := s.db.Exec(`DELETE FROM user_themes WHERE id = ? AND user_id = ?`, themeID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Theme nicht gefunden")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package storage
import (
"path/filepath"
"strings"
"testing"
)
func TestUserThemesArePrivateAndLimited(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "themes.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
css := `:root[data-theme="wald"] { --accent-primary: #15803d; }`
th, err := store.SaveUserTheme(anna.ID, "", "Wald", css)
if err != nil {
t.Fatalf("SaveUserTheme failed: %v", err)
}
if _, err := store.SaveUserTheme(anna.ID, th.ID, "Wald 2", css); err != nil {
t.Fatalf("update failed: %v", err)
}
if themes, _ := store.GetUserThemes(anna.ID); len(themes) != 1 || themes[0].Name != "Wald 2" {
t.Fatalf("expected one updated theme, got %+v", themes)
}
// Other users neither see, change nor delete it
if themes, _ := store.GetUserThemes(ben.ID); len(themes) != 0 {
t.Errorf("Ben must not see Anna's themes")
}
if _, err := store.SaveUserTheme(ben.ID, th.ID, "Gekapert", css); err == nil {
t.Errorf("Ben must not update Anna's theme")
}
if err := store.DeleteUserTheme(ben.ID, th.ID); err == nil {
t.Errorf("Ben must not delete Anna's theme")
}
if _, err := store.SaveUserTheme(anna.ID, "", "Riesig", strings.Repeat("a", MaxThemeCSSBytes+1)); err == nil {
t.Errorf("oversized theme must be rejected")
}
if _, err := store.SaveUserTheme(anna.ID, "", " ", css); err == nil {
t.Errorf("theme without name must be rejected")
}
if err := store.DeleteUserTheme(anna.ID, th.ID); err != nil {
t.Fatalf("delete failed: %v", err)
}
}
+289
View File
@@ -0,0 +1,289 @@
package storage
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// Sharing modes (system_settings.sharing_mode). They decide who can see whom; every way of finding
// people (directory, search, sharing, groups) goes through CanSee / CanShareWith.
const (
SharingPrivate = "private" // nobody sees anybody, sharing and groups are off
SharingOrganizations = "organizations" // only members of the same organization see each other
SharingOpen = "open" // everybody sees everybody
)
// ErrSharingDisabled is returned for sharing or group actions while the server runs in private mode.
var ErrSharingDisabled = errors.New("Teilen ist auf diesem Server deaktiviert")
// ErrTargetNotFound is returned both for unknown people/groups and for ones the caller may not see,
// so nobody can probe which accounts exist.
var ErrTargetNotFound = errors.New("Benutzer oder Gruppe nicht gefunden")
func NormalizeSharingMode(mode string) string {
switch mode {
case SharingPrivate, SharingOpen:
return mode
}
return SharingOrganizations
}
func (s *Store) SharingMode() string {
sys, _ := s.GetSystemSettings()
if sys == nil {
return SharingOrganizations
}
return NormalizeSharingMode(sys.SharingMode)
}
func (s *Store) userOrganization(userID string) string {
var org string
_ = s.db.QueryRow(`SELECT organization_id FROM users WHERE id = ?`, userID).Scan(&org)
return org
}
// CanSee reports whether viewer may find target and share with it. Server admins follow the same rule;
// they manage everybody through the admin endpoints instead.
func (s *Store) CanSee(viewerID, targetID string) bool {
if viewerID == "" || targetID == "" {
return false
}
if viewerID == targetID {
return true
}
switch s.SharingMode() {
case SharingOpen:
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, targetID).Scan(&n)
return n > 0
case SharingPrivate:
return false
}
org := s.userOrganization(viewerID)
return org != "" && org == s.userOrganization(targetID)
}
// CanShareWith reports whether owner may share something with a user or group: users must be visible,
// groups must have owner as a member. Sharing is only ever done by the owner of the shared item.
func (s *Store) CanShareWith(ownerID, targetType, targetID string) bool {
if s.SharingMode() == SharingPrivate {
return false
}
if targetType == "team" {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, targetID, ownerID).Scan(&n)
return n > 0
}
return ownerID != targetID && s.CanSee(ownerID, targetID)
}
// GetDiscoverableUsers returns everyone the viewer can see, the viewer included.
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
var rows *sql.Rows
var err error
switch s.SharingMode() {
case SharingOpen:
rows, err = s.db.Query(`SELECT ` + userSelectCols + ` FROM users ORDER BY display_name COLLATE NOCASE`)
case SharingPrivate:
rows, err = s.db.Query(`SELECT `+userSelectCols+` FROM users WHERE id = ?`, viewerID)
default:
rows, err = s.db.Query(
`SELECT `+userSelectCols+` FROM users
WHERE id = ? OR (organization_id != '' AND organization_id = (SELECT organization_id FROM users WHERE id = ?))
ORDER BY display_name COLLATE NOCASE`,
viewerID, viewerID,
)
}
if err != nil {
return []domain.User{}, err
}
defer rows.Close()
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
return users, nil
}
// visibleSQL is the SQL form of CanSee for two user ID expressions under the given mode.
func visibleSQL(mode, a, b string) string {
switch mode {
case SharingOpen:
return "1"
case SharingPrivate:
return fmt.Sprintf("(%s = %s)", a, b)
}
return fmt.Sprintf(
`(%[1]s = %[2]s OR EXISTS (SELECT 1 FROM users ua JOIN users ub ON ub.organization_id = ua.organization_id
WHERE ua.id = %[1]s AND ub.id = %[2]s AND ua.organization_id != ''))`, a, b)
}
// PruneSharing removes everything that is no longer allowed under mode: group memberships across
// organizations, shares with people the owner cannot see, and shares with groups the owner is not in.
// Task assignments follow via PruneTaskAssignees. With dryRun nothing changes and only the number of
// affected memberships and shares is returned (used to warn before tightening the mode).
func (s *Store) PruneSharing(mode string, dryRun bool) (int64, error) {
mode = NormalizeSharingMode(mode)
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
var stmts []string
switch mode {
case SharingPrivate:
stmts = append(stmts, `DELETE FROM team_members`)
case SharingOrganizations:
// A group belongs to its creator's organization (teams.org_id)
stmts = append(stmts, `DELETE FROM team_members WHERE
(SELECT organization_id FROM users WHERE id = team_members.user_id) != (SELECT org_id FROM teams WHERE id = team_members.team_id)
OR (SELECT org_id FROM teams WHERE id = team_members.team_id) = ''`)
}
type shareTable struct{ table, owner, userCol, teamCol string }
tables := []shareTable{
{"doc_shares", "(SELECT user_id FROM documents WHERE id = doc_shares.doc_id)", "doc_shares.user_id", ""},
{"doc_team_shares", "(SELECT user_id FROM documents WHERE id = doc_team_shares.doc_id)", "", "doc_team_shares.team_id"},
{"task_list_shares", "(SELECT user_id FROM task_lists WHERE id = task_list_shares.task_list_id)", "task_list_shares.target_id", "task_list_shares.target_id"},
{"list_shares", "(SELECT user_id FROM lists WHERE id = list_shares.list_id)", "list_shares.target_id", "list_shares.target_id"},
{"workspace_folder_shares", "(SELECT owner_id FROM workspace_shared_folders WHERE id = workspace_folder_shares.folder_id)", "workspace_folder_shares.target_id", "workspace_folder_shares.target_id"},
}
for _, t := range tables {
hasType := t.userCol != "" && t.teamCol != ""
if t.userCol != "" {
cond := "NOT " + visibleSQL(mode, t.owner, t.userCol)
if hasType {
cond = "target_type = 'user' AND " + cond
}
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
}
if t.teamCol != "" {
cond := fmt.Sprintf(`NOT EXISTS (SELECT 1 FROM team_members WHERE team_id = %s AND user_id = %s)`, t.teamCol, t.owner)
if hasType {
cond = "target_type = 'team' AND " + cond
}
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
}
}
var affected int64
for _, q := range stmts {
res, err := tx.Exec(q)
if err != nil {
return 0, fmt.Errorf("prune sharing: %w", err)
}
n, _ := res.RowsAffected()
affected += n
}
// Groups without members disappear; groups that lost their last admin get one
if _, err := tx.Exec(`DELETE FROM teams WHERE id NOT IN (SELECT team_id FROM team_members)`); err != nil {
return 0, err
}
if _, err := tx.Exec(`UPDATE team_members SET role = 'admin' WHERE rowid IN (
SELECT MIN(rowid) FROM team_members GROUP BY team_id HAVING SUM(role = 'admin') = 0)`); err != nil {
return 0, err
}
if dryRun {
return affected, nil
}
if err := tx.Commit(); err != nil {
return 0, err
}
s.PruneTaskAssignees()
return affected, nil
}
// pruneSharingNow applies the current mode after a membership or organization change.
func (s *Store) pruneSharingNow() {
_, _ = s.PruneSharing(s.SharingMode(), false)
}
// --- ORGANIZATIONS (managed by server admins only) ---
func (s *Store) GetOrganizations() ([]domain.Organization, error) {
rows, err := s.db.Query(
`SELECT o.id, o.name, o.created_at, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id)
FROM organizations o ORDER BY o.name COLLATE NOCASE`,
)
if err != nil {
return []domain.Organization{}, err
}
defer rows.Close()
orgs := make([]domain.Organization, 0)
for rows.Next() {
var o domain.Organization
if rows.Scan(&o.ID, &o.Name, &o.CreatedAt, &o.MemberCount) == nil {
orgs = append(orgs, o)
}
}
return orgs, nil
}
func (s *Store) CreateOrganization(name string) (*domain.Organization, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name der Organisation ist erforderlich")
}
org := &domain.Organization{ID: domain.NewID("org"), Name: name, CreatedAt: domain.NowISO()}
if _, err := s.db.Exec(`INSERT INTO organizations (id, name, created_at) VALUES (?, ?, ?)`, org.ID, org.Name, org.CreatedAt); err != nil {
return nil, err
}
return org, nil
}
func (s *Store) RenameOrganization(orgID, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return errors.New("Name der Organisation ist erforderlich")
}
res, err := s.db.Exec(`UPDATE organizations SET name = ? WHERE id = ?`, name, orgID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Organisation nicht gefunden")
}
return nil
}
// DeleteOrganization removes the organization; its members end up without one and lose what they
// shared with each other.
func (s *Store) DeleteOrganization(orgID string) error {
if _, err := s.db.Exec(`UPDATE users SET organization_id = '' WHERE organization_id = ?`, orgID); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID); err != nil {
return err
}
s.pruneSharingNow()
return nil
}
// SetUserOrganization moves a user into an organization ("" = none). Leaving one drops the user's
// groups, shares and task assignments there.
func (s *Store) SetUserOrganization(userID, orgID string) error {
orgID = strings.TrimSpace(orgID)
if orgID != "" {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&n)
if n == 0 {
return errors.New("Organisation nicht gefunden")
}
}
res, err := s.db.Exec(`UPDATE users SET organization_id = ? WHERE id = ?`, orgID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Benutzer nicht gefunden")
}
s.pruneSharingNow()
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package storage
import (
"errors"
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func setSharingMode(t *testing.T, store *Store, mode string) {
t.Helper()
sys, _ := store.GetSystemSettings()
sys.SharingMode = mode
if err := store.UpdateSystemSettings(sys); err != nil {
t.Fatalf("UpdateSystemSettings: %v", err)
}
}
func TestVisibilityFollowsOrganizations(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "visibility.db"))
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
if store.SharingMode() != SharingOrganizations {
t.Fatalf("default mode should be organizations, got %q", store.SharingMode())
}
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
carl, _ := store.CreateUser("carl@example.com", "hash", "Carl", "admin")
dora, _ := store.CreateUser("dora@example.com", "hash", "Dora", "member")
org1, _ := store.CreateOrganization("Familie")
org2, _ := store.CreateOrganization("Firma")
_ = store.SetUserOrganization(anna.ID, org1.ID)
_ = store.SetUserOrganization(ben.ID, org1.ID)
_ = store.SetUserOrganization(carl.ID, org2.ID)
// Who sees whom (server admins follow the same rule)
cases := []struct {
a, b *domain.User
want bool
}{
{anna, ben, true}, {ben, anna, true}, {anna, carl, false}, {carl, anna, false},
{dora, anna, false}, {anna, dora, false}, {dora, dora, true},
}
for _, c := range cases {
if got := store.CanSee(c.a.ID, c.b.ID); got != c.want {
t.Errorf("CanSee(%s, %s) = %v, want %v", c.a.DisplayName, c.b.DisplayName, got, c.want)
}
}
if users, _ := store.GetDiscoverableUsers(anna.ID); len(users) != 2 {
t.Errorf("Anna should see herself and Ben, got %d users", len(users))
}
if users, _ := store.GetDiscoverableUsers(dora.ID); len(users) != 1 || users[0].ID != dora.ID {
t.Errorf("Dora without organization should only see herself, got %+v", users)
}
// Sharing: visible people only; an invisible e-mail looks like an unknown one
doc, _ := store.CreateDocument(anna.ID, "", "Einkauf", "📄", "", nil, nil, false)
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
t.Fatalf("share with Ben failed: %v", err)
}
if _, err := store.CreateDocShare(anna.ID, doc.ID, "carl@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
t.Errorf("share with invisible Carl must look like not found, got %v", err)
}
if _, err := store.CreateDocShare(anna.ID, doc.ID, "nobody@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
t.Errorf("share with unknown address must be not found, got %v", err)
}
// Only the owner shares a list
list, _ := store.CreateList(anna.ID, "Wocheneinkauf", "🛒", "shopping")
if _, err := store.AddListShare(anna.ID, list.ID, "user", ben.ID, "editor"); err != nil {
t.Fatalf("list share with Ben failed: %v", err)
}
if _, err := store.AddListShare(ben.ID, list.ID, "user", anna.ID, "editor"); err == nil {
t.Errorf("an editor must not reshare a list")
}
// Groups: only members may share with them
team, _ := store.CreateTeam(org1.ID, "WG", "")
_ = store.AddTeamMember(team.ID, anna.ID, "admin")
_ = store.AddTeamMember(team.ID, ben.ID, "member")
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "editor", anna.ID); err != nil {
t.Fatalf("group share failed: %v", err)
}
carlList, _ := store.CreateTaskList(carl.ID, "", "", "Fremd", "🔒", "")
if _, err := store.AddTaskListShare(carlList.ID, "team", team.ID, "editor", carl.ID); !errors.Is(err, ErrTargetNotFound) {
t.Errorf("non-member must not share with a group, got %v", err)
}
task, _ := store.CreateThoughtWithDetails(anna.ID, "Bad putzen", "normal", "manual", "", tl.ID, "todo", "")
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", tl.ID)
if _, err := store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil {
t.Fatalf("assign Ben failed: %v", err)
}
// A dry run counts without changing anything
n, err := store.PruneSharing(SharingPrivate, true)
if err != nil || n == 0 {
t.Fatalf("dry run should report affected rows, got %d (%v)", n, err)
}
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 1 {
t.Fatalf("dry run must not remove shares")
}
// Ben moves to the other organization: he loses shares, group and assignment
if err := store.SetUserOrganization(ben.ID, org2.ID); err != nil {
t.Fatalf("SetUserOrganization failed: %v", err)
}
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 0 {
t.Errorf("doc share with Ben should be gone, got %+v", shares)
}
if l, _ := store.GetListByID(anna.ID, list.ID); len(l.Shares) != 0 {
t.Errorf("list share with Ben should be gone, got %+v", l.Shares)
}
if members, _ := store.GetTeamMembers(team.ID); len(members) != 1 {
t.Errorf("Ben should have left the group, members: %+v", members)
}
if th, _ := store.GetThoughtByID(anna.ID, task.ID); len(th.Assignees) != 0 {
t.Errorf("Ben's assignment should be gone, got %v", th.Assignees)
}
// Open mode: everyone sees everyone
setSharingMode(t, store, SharingOpen)
if !store.CanSee(anna.ID, carl.ID) || !store.CanSee(dora.ID, anna.ID) {
t.Errorf("open mode should make everyone visible")
}
// Private mode: no sharing at all, groups disappear
if _, err := store.PruneSharing(SharingPrivate, false); err != nil {
t.Fatalf("PruneSharing private failed: %v", err)
}
setSharingMode(t, store, SharingPrivate)
if store.CanSee(anna.ID, ben.ID) || store.CanShareWith(anna.ID, "team", team.ID) {
t.Errorf("private mode must not allow seeing or sharing")
}
if tls, _ := store.GetTaskListShares(tl.ID); len(tls) != 0 {
t.Errorf("private mode should remove all task list shares, got %+v", tls)
}
if _, err := store.GetTeamByID(team.ID); err == nil {
t.Errorf("groups should be gone in private mode")
}
}
func TestOutdatedSchemaIsWiped(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
store, err := NewStore(path)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
_, _ = store.CreateUser("old@example.com", "hash", "Alt", "member")
_, _ = store.db.Exec(`PRAGMA user_version = 1`)
_ = store.Close()
store, err = NewStore(path)
if err != nil {
t.Fatalf("reopen failed: %v", err)
}
defer store.Close()
if n, _ := store.CountUsers(); n != 0 {
t.Fatalf("outdated database should start empty, found %d users", n)
}
var v int
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
if v != schemaVersion {
t.Fatalf("user_version = %d, want %d", v, schemaVersion)
}
}