feat: organizations decide who sees whom; sharing only by owners
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s

- Server admins manage organizations (one per user); new admin section,
  organization on user creation and invites, setup creates the first one
- Sharing modes private / organizations / open, enforced centrally
  (CanSee, CanShareWith) for directory, all shares and groups; unknown and
  invisible targets give the same error
- Losing visibility (org change, mode change, leaving a group) prunes shares,
  group memberships and task assignments; dry run warns before tightening
- Only owners share; editors can no longer reshare lists or remove others'
  shares; legacy task_lists.team_id and old /organizations endpoints removed
  (FLAW-06, FLAW-10, FLAW-24)
- Schema version: outdated databases are wiped and recreated on start
- Fix setup sample data (createSampleData was ignored); setup and admin
  use a shared ToggleSwitch instead of checkboxes
- Docs: sharing-and-permissions rewritten, ui-patterns, REST, DB, flaws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 22:00:33 +01:00
1 parent 73fad1c5db
commit 84395f3322
26 files changed
+1421 -609

No files matched your search

+11 -8
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,7 +26,7 @@ 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 |
@@ -130,21 +130,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
+7 -7
View File
@@ -9,8 +9,8 @@ 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` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
@@ -20,7 +20,7 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|---|---|---|
| `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_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), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `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>'`.
@@ -54,9 +54,9 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
### 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
@@ -78,8 +78,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 -2
View File
@@ -23,8 +23,9 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
- **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, Ändern von `team_id`, 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.
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
- **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
+27 -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. |
@@ -21,7 +22,6 @@
- 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.
- **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 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.
## Aufgabenlisten teilen (Details)
@@ -32,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
@@ -53,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).
+7
View File
@@ -32,6 +32,13 @@ Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt
| 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.
+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
+16 -7
View File
@@ -32,6 +32,7 @@ import {
triggerHaptic,
connectWebSocket,
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import {
AVAILABLE_THEMES,
@@ -273,12 +274,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'),
},
]),
],
},
]);
@@ -618,7 +623,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',
@@ -802,6 +807,7 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
onClick={() => {
triggerHaptic(10);
@@ -815,6 +821,7 @@ export const App: React.FC = () => {
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
onClick={handleOpenHelp}
@@ -1367,6 +1374,7 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span>
</button>
{!isSharingDisabled() && (
<button
type="button"
onClick={() => {
@@ -1381,6 +1389,7 @@ export const App: React.FC = () => {
<Users className="w-4 h-4 text-accent" />
<span>Gruppen</span>
</button>
)}
<button
type="button"
+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>
+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>
+5 -4
View File
@@ -224,10 +224,10 @@ export const HelpView: React.FC<HelpViewProps> = () => {
<div className="border-b border-border pb-2.5">
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-accent" />
<span>Aufgaben, Listen & Team-Zusammenarbeit</span>
<span>Aufgaben, Listen & Zusammenarbeit</span>
</h2>
<p className="text-xs text-muted mt-0.5">
Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit.
Verwalte Aufgabenlisten und Einkaufslisten und teile sie in Echtzeit mit Personen oder Gruppen.
</p>
</div>
@@ -238,8 +238,9 @@ export const HelpView: React.FC<HelpViewProps> = () => {
<span>Aufgabenlisten teilen & Avatar-Stack</span>
</h3>
<p className="text-muted leading-relaxed">
Jede Aufgabenliste kann über den Button <strong>„Teilen"</strong> einem gemeinsamen Team-Space zugeordnet werden.
Im Header und auf den Tabs zeigt der Avatar-Stack sofort an, wer Eigentümer ist und welche Teammitglieder Zugriff haben.
Über den Button <strong>„Teilen"</strong> gibt der Eigentümer eine Aufgabenliste an Personen oder Gruppen frei.
Welche Personen du findest, legt der Server fest: je nach Einstellung alle, nur Mitglieder deiner Organisation oder niemand.
Auf den Tabs zeigen kleine Profilbilder, wer Zugriff hat.
</p>
</div>
@@ -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: '',
@@ -80,9 +117,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 || '',
@@ -116,8 +154,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 +176,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 +263,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 +350,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 +449,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 +520,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 +569,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 +609,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 +634,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 +684,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 +742,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 +764,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 +814,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 +865,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 +1000,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 +1037,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 +1092,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>
+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>
+2 -2
View File
@@ -1398,7 +1398,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
<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) {
@@ -1448,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';
+51 -38
View File
@@ -17,6 +17,7 @@ import {
SpaceMember,
SpaceShare,
SystemSettings,
SharingMode,
Team,
TaskList,
TaskListShare,
@@ -124,10 +125,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> {
@@ -147,7 +157,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 }> {
@@ -830,41 +840,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',
@@ -924,6 +900,7 @@ export const api = {
password: string;
displayName: string;
role: string;
organizationId?: string;
}): Promise<User> {
return request<User>('/api/v1/admin/users', {
method: 'POST',
@@ -966,7 +943,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',
@@ -989,6 +966,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',
+10 -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;
@@ -101,8 +107,6 @@ export interface TaskList {
userId: string;
spaceId?: string;
spaceName?: string;
teamId?: string;
teamName?: string;
ownerId?: string;
ownerName?: string;
isOwner?: boolean;
@@ -118,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;
}
@@ -146,7 +150,7 @@ export interface TeamMember {
}
export interface SystemSettings {
sharingMode: 'open' | 'teams_only' | 'strict_private';
sharingMode: SharingMode;
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
+100 -69
View File
@@ -191,12 +191,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 +205,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 +224,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)
})
})
@@ -453,6 +456,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
})
}
@@ -470,7 +474,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")
@@ -502,13 +506,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)
}
@@ -583,8 +592,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
}
}
@@ -2244,7 +2254,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"`
}
@@ -2253,7 +2262,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
@@ -2354,9 +2363,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
@@ -2364,8 +2372,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"`
}
@@ -2373,7 +2380,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
@@ -2381,45 +2388,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 {
@@ -2438,20 +2452,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 {
@@ -2534,17 +2553,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"
@@ -2621,10 +2642,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")
@@ -2652,6 +2674,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)
}
@@ -2760,10 +2787,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")
@@ -2772,7 +2799,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
@@ -2807,10 +2834,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)
}
@@ -3037,4 +3069,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))
}
}
+17 -18
View File
@@ -131,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
@@ -233,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
@@ -249,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 {
@@ -277,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"`
+1
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)
}
+143 -313
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
@@ -57,7 +58,55 @@ func (s *Store) Close() error {
return s.db.Close()
}
// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt
// on start: MindSift is not in production yet, so breaking changes need no migration path.
const schemaVersion = 2
func (s *Store) resetOutdatedSchema() error {
var version int
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
return err
}
if version >= schemaVersion {
return nil
}
conn, err := s.db.Conn(context.Background())
if err != nil {
return err
}
defer conn.Close()
ctx := context.Background()
rows, err := conn.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
return err
}
var tables []string
for rows.Next() {
var name string
if rows.Scan(&name) == nil {
tables = append(tables, name)
}
}
_ = rows.Close()
if len(tables) > 0 {
slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion)
}
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
return err
}
for _, t := range tables {
if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS "`+strings.ReplaceAll(t, `"`, `""`)+`"`); err != nil {
return err
}
}
_, err = conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
return err
}
func (s *Store) migrate() error {
if err := s.resetOutdatedSchema(); err != nil {
return fmt.Errorf("reset outdated schema: %w", err)
}
schema := `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
@@ -79,15 +128,17 @@ func (s *Store) migrate() error {
custom_webhook_url TEXT NOT NULL DEFAULT '',
notify_before_minutes INTEGER NOT NULL DEFAULT 30,
avatar_url TEXT NOT NULL DEFAULT '',
organization_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
CREATE TABLE IF NOT EXISTS user_invites (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'member',
team_id TEXT NOT NULL DEFAULT '',
organization_id TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_by TEXT NOT NULL DEFAULT '',
@@ -124,7 +175,6 @@ func (s *Store) migrate() error {
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
space_id TEXT DEFAULT '',
team_id TEXT DEFAULT '',
title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📋',
color TEXT NOT NULL DEFAULT '#2563eb',
@@ -135,17 +185,17 @@ func (s *Store) migrate() error {
);
CREATE INDEX IF NOT EXISTS idx_task_lists_user ON task_lists(user_id, updated_at DESC);
-- Organizations are defined by server admins and decide who can see whom (sharing_mode = organizations)
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
created_at TEXT NOT NULL
);
-- Teams are the user-managed groups ("Gruppen"); org_id is the creator's organization
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT 'default',
org_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
@@ -351,7 +401,6 @@ func (s *Store) migrate() error {
"CREATE INDEX IF NOT EXISTS idx_doc_shares_user ON doc_shares(user_id);",
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
"ALTER TABLE task_lists ADD COLUMN columns TEXT NOT NULL DEFAULT '[]';",
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
@@ -424,7 +473,7 @@ func (s *Store) migrate() error {
// Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
('sharing_mode', 'teams_only'),
('sharing_mode', 'organizations'),
('allow_registration', 'false'),
('instance_name', 'MindSift'),
('smtp_host', ''),
@@ -434,32 +483,6 @@ func (s *Store) migrate() error {
('smtp_from', ''),
('smtp_tls', 'true')`)
// Migrate existing teams table if it still has a legacy FOREIGN KEY to organizations
var teamsTableSQL string
_ = s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='teams'`).Scan(&teamsTableSQL)
if strings.Contains(strings.ToUpper(teamsTableSQL), "ORGANIZATIONS") {
if conn, err := s.db.Conn(context.Background()); err == nil {
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`)
_, _ = conn.ExecContext(context.Background(), `
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS teams_migrated (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
INSERT OR IGNORE INTO teams_migrated (id, org_id, name, description, created_at)
SELECT id, COALESCE(NULLIF(org_id, ''), 'default'), name, COALESCE(description, ''), created_at FROM teams;
DROP TABLE teams;
ALTER TABLE teams_migrated RENAME TO teams;
COMMIT;
`)
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`)
_ = conn.Close()
}
}
// Clean up legacy roles in team_members and users
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
@@ -479,7 +502,8 @@ func (s *Store) migrate() error {
}
s.migrateAnchorTags()
return nil
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err
}
// EnsureDefaultUser is retained strictly for unit test convenience
@@ -508,7 +532,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
// --- USERS ---
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, organization_id, created_at`
func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",")
@@ -531,7 +555,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.OrganizationID, &u.CreatedAt,
)
if err != nil {
return nil, err
@@ -660,8 +684,13 @@ func (s *Store) EnsureDemoAccounts() error {
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
// 3. Sample data for the admin, partly shared with the demo user (only on first start)
// 3. Sample data for the admin, partly shared with the demo user (only on first start). Both accounts
// share an organization so they can see each other in the default sharing mode.
if adminCreated {
if org, err := s.CreateOrganization("Demo"); err == nil {
_ = s.SetUserOrganization(adminUser.ID, org.ID)
_ = s.SetUserOrganization(normalUser.ID, org.ID)
}
_ = s.SeedStarterContent(adminUser)
s.seedDemoShares(adminUser, normalUser)
}
@@ -701,46 +730,6 @@ func (s *Store) GetAllUsers() ([]domain.User, error) {
return users, nil
}
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
viewer, err := s.GetUserByID(viewerID)
if err != nil {
return []domain.User{}, nil
}
sys, _ := s.GetSystemSettings()
mode := "teams_only"
if sys != nil && sys.SharingMode != "" {
mode = sys.SharingMode
}
if viewer.Role == "admin" || mode == "open" || mode == "teams_only" {
return s.GetAllUsers()
}
if mode == "strict_private" {
return []domain.User{*viewer}, nil
}
// users sharing at least one team, organization or space with viewer
rows, err := s.db.Query(
`SELECT DISTINCT `+userSelectCols+` FROM users WHERE id = ? OR id IN (
SELECT tm2.user_id FROM team_members tm1
JOIN team_members tm2 ON tm1.team_id = tm2.team_id
WHERE tm1.user_id = ?
)`,
viewerID, viewerID,
)
if err != nil {
return []domain.User{*viewer}, nil
}
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
_ = rows.Close()
return users, nil
}
func (s *Store) UpdateUser(
userID string,
displayName, email, role *string,
@@ -821,6 +810,8 @@ func (s *Store) DeleteUser(userID string) error {
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
// Shares pointing at the deleted account are no longer visible to anyone
s.pruneSharingNow()
return err
}
@@ -975,7 +966,6 @@ func scanThought(rows interface {
func (s *Store) accessibleTaskListIDsQuery() string {
return `SELECT id FROM task_lists
WHERE user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
@@ -987,7 +977,7 @@ func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error
row := s.db.QueryRow(
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
thoughtID, userID, userID, userID, userID, userID,
thoughtID, userID, userID, userID, userID,
)
t, err := scanThought(row)
if err != nil {
@@ -1045,7 +1035,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
AND task_list_id = ?
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID, taskListID,
userID, userID, userID, userID, taskListID,
)
} else {
rows, err = s.db.Query(
@@ -1054,7 +1044,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
ORDER BY is_completed ASC, sort_order ASC, created_at DESC`,
userID, userID, userID, userID, userID,
userID, userID, userID, userID,
)
}
if err != nil {
@@ -3080,7 +3070,7 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
if cleanType == "team" {
err = s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName)
if err != nil {
return nil, fmt.Errorf("gruppe nicht gefunden")
return nil, ErrTargetNotFound
}
} else {
var u *domain.User
@@ -3097,7 +3087,7 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
if s.db.QueryRow(`SELECT id, name FROM teams WHERE id = ?`, cleanTarget).Scan(&targetID, &targetName) == nil {
cleanType = "team"
} else {
return nil, fmt.Errorf("benutzer nicht gefunden")
return nil, ErrTargetNotFound
}
} else {
if u.ID == f.OwnerID {
@@ -3110,6 +3100,10 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde
}
}
if !s.CanShareWith(currentUserID, cleanType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
shareID := domain.NewID("fsh")
@@ -3175,7 +3169,8 @@ func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error
}
cleanID := strings.TrimSpace(shareID)
if !f.IsOwner && f.Permission != "write" {
// Only the owner manages shares; everyone else can only leave the folder
if !f.IsOwner {
_, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`,
folderID, currentUserID, cleanID, cleanID)
_, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)
@@ -3295,8 +3290,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
cleanTeamID := strings.TrimSpace(teamID)
var teamName string
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
if err != nil {
return nil, fmt.Errorf("team nicht gefunden")
if err != nil || !s.CanShareWith(currentUserID, "team", cleanTeamID) {
return nil, ErrTargetNotFound
}
permission = normalizeSharePermission(permission)
@@ -3370,12 +3365,15 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
}
return nil, fmt.Errorf("benutzer nicht gefunden")
return nil, ErrTargetNotFound
}
if targetUser.ID == doc.UserID {
return nil, fmt.Errorf("dokument gehört bereits diesem Benutzer")
}
if !s.CanShareWith(currentUserID, "user", targetUser.ID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
shareID := domain.NewID("share")
@@ -3409,7 +3407,8 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
if err != nil {
return err
}
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetID {
// Only the owner manages shares; everyone else can only remove their own
if !doc.IsOwner && currentUserID != targetID {
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
}
@@ -3427,22 +3426,15 @@ func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
return err
}
// GetCollaborators returns the active people the user can share with (everyone visible except the user).
func (s *Store) GetCollaborators(currentUserID string) ([]domain.User, error) {
rows, err := s.db.Query(
`SELECT id, email, display_name, role, is_active, avatar_url, created_at FROM users WHERE id != ? AND is_active = 1 ORDER BY display_name ASC`,
currentUserID,
)
visible, err := s.GetDiscoverableUsers(currentUserID)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]domain.User, 0)
for rows.Next() {
var u domain.User
var activeInt int
if err := rows.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &activeInt, &u.AvatarURL, &u.CreatedAt); err == nil {
u.IsActive = activeInt == 1
users := make([]domain.User, 0, len(visible))
for _, u := range visible {
if u.ID != currentUserID && u.IsActive {
users = append(users, u)
}
}
@@ -3511,23 +3503,22 @@ func (s *Store) AddListShare(currentUserID, listID, targetType, targetID, role s
targetUser, _ = s.GetUserByEmail(targetID)
}
if targetUser == nil {
return nil, fmt.Errorf("benutzer nicht gefunden: %s", targetID)
return nil, ErrTargetNotFound
}
targetID = targetUser.ID
}
// Verify current user is owner or editor of the list
// Only the owner shares
var ownerID string
err := s.db.QueryRow(`SELECT user_id FROM lists WHERE id = ?`, listID).Scan(&ownerID)
if err != nil {
return nil, errors.New("liste nicht gefunden")
}
if ownerID != currentUserID {
var myRole string
err = s.db.QueryRow(`SELECT role FROM list_shares WHERE list_id = ? AND target_type = 'user' AND target_id = ?`, listID, currentUserID).Scan(&myRole)
if err != nil || myRole != "editor" {
return nil, errors.New("keine Berechtigung zum Teilen dieser Liste")
}
return nil, errors.New("nur der Eigentümer kann die Liste freigeben")
}
if !s.CanShareWith(currentUserID, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
@@ -4206,7 +4197,6 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
ID: domain.NewID("tkl"),
UserID: userID,
SpaceID: spaceID,
TeamID: teamID,
Title: strings.TrimSpace(title),
Icon: icon,
Color: color,
@@ -4216,13 +4206,13 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
IsOwner: true,
}
_, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
`INSERT INTO task_lists (id, user_id, space_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tl.ID, tl.UserID, tl.SpaceID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
)
if err != nil {
return nil, err
}
if teamID != "" {
if teamID != "" && s.CanShareWith(userID, "team", teamID) {
_, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID)
}
return s.GetTaskListByID(userID, tl.ID)
@@ -4236,8 +4226,8 @@ var ErrAssigneeNoAccess = errors.New("Aufgaben können nur Personen mit Zugriff
// GetTaskListPermission returns "owner", "editor", "viewer" or "" (no access) for the given user.
func (s *Store) GetTaskListPermission(userID, listID string) string {
var ownerID, teamID string
if err := s.db.QueryRow(`SELECT user_id, COALESCE(team_id, '') FROM task_lists WHERE id = ?`, listID).Scan(&ownerID, &teamID); err != nil {
var ownerID string
if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&ownerID); err != nil {
return ""
}
if ownerID == userID {
@@ -4267,15 +4257,6 @@ func (s *Store) GetTaskListPermission(userID, listID string) string {
}
_ = rows.Close()
}
if perm == "" && teamID != "" {
// Legacy team assignment without a share row grants edit access
var isMember, hasShareRow int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&isMember)
_ = s.db.QueryRow(`SELECT COUNT(*) FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team' AND target_id = ?`, listID, teamID).Scan(&hasShareRow)
if isMember > 0 && hasShareRow == 0 {
perm = "editor"
}
}
return perm
}
@@ -4303,6 +4284,9 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
if permission != "viewer" {
permission = "editor"
}
if !s.CanShareWith(createdBy, targetType, targetID) {
return nil, ErrTargetNotFound
}
now := domain.NowISO()
share := &domain.TaskListShare{
ID: domain.NewID("tls"),
@@ -4325,7 +4309,6 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
if targetType == "team" {
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
} else {
_ = s.db.QueryRow(`SELECT display_name, email FROM users WHERE id = ?`, targetID).Scan(&share.TargetName, &share.Email)
@@ -4334,16 +4317,10 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, permission, c
}
func (s *Store) RemoveTaskListShare(taskListID, shareIDOrTargetID string) error {
var targetType, targetID string
_ = s.db.QueryRow(`SELECT target_type, target_id FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`, taskListID, shareIDOrTargetID, shareIDOrTargetID).Scan(&targetType, &targetID)
_, err := s.db.Exec(
`DELETE FROM task_list_shares WHERE task_list_id = ? AND (id = ? OR target_id = ?)`,
taskListID, shareIDOrTargetID, shareIDOrTargetID,
)
if targetType == "team" {
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = '' WHERE id = ? AND team_id = ?`, taskListID, targetID)
}
s.PruneTaskAssignees()
return err
}
@@ -4395,13 +4372,12 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
return ids
}
// taskListAccessSQL selects every user ID with access to the task list given as the single parameter.
// It mirrors GetTaskListUserIDs and is correlated into PruneTaskAssignees.
// taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of
// shared groups. %[1]s is the list ID expression ("?" or a correlated column).
const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = %[1]s AND team_id != '')`
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = %[1]s AND target_type = 'team')`
func (s *Store) getTaskAssignees(taskID string) []string {
ids := s.queryUserIDs(`SELECT user_id FROM task_assignees WHERE task_id = ? ORDER BY created_at ASC`, taskID)
@@ -4518,20 +4494,12 @@ func (s *Store) PruneTaskAssignees() {
)
}
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams
// and members of the legacy team_id.
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams.
func (s *Store) GetTaskListUserIDs(listID string) []string {
if listID == "" {
return []string{}
}
return s.queryUserIDs(
`SELECT user_id FROM task_lists WHERE id = ?
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team')
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = ? AND team_id != '')`,
listID, listID, listID, listID,
)
return s.queryUserIDs(fmt.Sprintf(taskListAccessSQL, "?"), listID, listID, listID)
}
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
@@ -4562,25 +4530,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
Role: "owner",
}
// 2. Team members if tl.TeamID != ""
if tl.TeamID != "" {
rows, err := s.db.Query(
`SELECT u.id, u.display_name, u.email, u.avatar_url FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE tm.team_id = ?`,
tl.TeamID,
)
if err == nil {
for rows.Next() {
var m domain.TaskListMember
if err := rows.Scan(&m.UserID, &m.DisplayName, &m.Email, &m.AvatarURL); err == nil {
m.Role = "member"
memberMap[m.UserID] = m
}
}
_ = rows.Close()
}
}
// 3. From task_list_shares
// 2. From task_list_shares
for _, sh := range tl.Shares {
if sh.TargetType == "team" {
rows, err := s.db.Query(
@@ -4615,17 +4565,16 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query(
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
ORDER BY updated_at DESC`,
userID, userID, userID, userID,
userID, userID, userID,
)
if err != nil {
return []domain.TaskList{}, err
@@ -4642,7 +4591,7 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
for rows.Next() {
var tl domain.TaskList
var colsJSON string
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
@@ -4663,10 +4612,6 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
).Scan(&list[i].TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, list[i].UserID).Scan(&list[i].OwnerName)
if list[i].TeamID != "" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, list[i].TeamID).Scan(&list[i].TeamName)
}
shares, _ := s.GetTaskListShares(list[i].ID)
list[i].Shares = shares
list[i].Permission = s.GetTaskListPermission(userID, list[i].ID)
@@ -4679,19 +4624,18 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
var tl domain.TaskList
var colsJSON string
err := s.db.QueryRow(
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
`SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
FROM task_lists
WHERE id = ? AND (
user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
OR id IN (
SELECT task_list_id FROM task_list_shares
WHERE (target_type = 'user' AND target_id = ?)
OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
)
)`,
listID, userID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
listID, userID, userID, userID,
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
if err != nil {
return nil, err
}
@@ -4707,9 +4651,6 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
}
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
if tl.TeamID != "" {
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, tl.TeamID).Scan(&tl.TeamName)
}
shares, _ := s.GetTaskListShares(tl.ID)
tl.Shares = shares
tl.Permission = s.GetTaskListPermission(userID, tl.ID)
@@ -4717,7 +4658,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
return &tl, nil
}
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
tl, err := s.GetTaskListByID(userID, listID)
if err != nil {
return nil, err
@@ -4740,28 +4681,15 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if columns != nil {
tl.Columns = *columns
}
if teamID != nil {
oldTeamID := tl.TeamID
tl.TeamID = *teamID
if oldTeamID != "" && oldTeamID != *teamID {
_ = s.RemoveTaskListShare(listID, oldTeamID)
}
if *teamID != "" && *teamID != oldTeamID {
_, _ = s.AddTaskListShare(listID, "team", *teamID, "editor", userID)
}
}
tl.UpdatedAt = domain.NowISO()
colsJSON, _ := json.Marshal(tl.Columns)
_, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, string(colsJSON), tl.UpdatedAt, listID,
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
tl.Title, tl.Icon, tl.Color, tl.SpaceID, string(colsJSON), tl.UpdatedAt, listID,
)
if err != nil {
return nil, err
}
if teamID != nil {
s.PruneTaskAssignees()
}
return s.GetTaskListByID(userID, listID)
}
@@ -4775,96 +4703,12 @@ func (s *Store) DeleteTaskList(userID, listID string) error {
// --- ORGANIZATIONS & TEAMS ---
func (s *Store) CreateOrganization(ownerID, name string) (*domain.Organization, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("organization name required")
}
org := &domain.Organization{
ID: domain.NewID("org"),
Name: name,
OwnerID: ownerID,
Teams: []domain.Team{},
CreatedAt: domain.NowISO(),
}
_, err := s.db.Exec(
`INSERT INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
org.ID, org.Name, org.OwnerID, org.CreatedAt,
)
if err != nil {
return nil, err
}
return org, nil
}
func (s *Store) DeleteOrganization(orgID string) error {
_, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID)
return err
}
func (s *Store) GetOrganizationsForUser(userID string) ([]domain.Organization, error) {
viewer, _ := s.GetUserByID(userID)
var rows *sql.Rows
var err error
if viewer != nil && viewer.Role == "admin" {
rows, err = s.db.Query(
`SELECT id, name, owner_id, created_at FROM organizations ORDER BY created_at ASC`,
)
} else {
rows, err = s.db.Query(
`SELECT DISTINCT o.id, o.name, o.owner_id, o.created_at
FROM organizations o
LEFT JOIN teams t ON t.org_id = o.id
LEFT JOIN team_members tm ON tm.team_id = t.id
WHERE o.owner_id = ? OR tm.user_id = ?
ORDER BY o.created_at ASC`,
userID, userID,
)
}
if err != nil {
return []domain.Organization{}, err
}
orgs := make([]domain.Organization, 0)
for rows.Next() {
var o domain.Organization
if err := rows.Scan(&o.ID, &o.Name, &o.OwnerID, &o.CreatedAt); err == nil {
o.Teams = []domain.Team{}
orgs = append(orgs, o)
}
}
_ = rows.Close()
for i := range orgs {
teams, _ := s.GetTeamsForOrg(orgs[i].ID)
orgs[i].Teams = teams
}
return orgs, nil
}
func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("team name required")
}
orgID = strings.TrimSpace(orgID)
if orgID == "" {
orgID = "default"
}
var orgExists int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&orgExists)
if orgExists == 0 {
var ownerID string
_ = s.db.QueryRow(`SELECT id FROM users ORDER BY created_at ASC LIMIT 1`).Scan(&ownerID)
if ownerID != "" {
_, _ = s.db.Exec(
`INSERT OR IGNORE INTO organizations (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`,
orgID, "Standard", ownerID, domain.NowISO(),
)
}
}
team := &domain.Team{
ID: domain.NewID("team"),
@@ -4878,18 +4722,6 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
)
if err != nil && strings.Contains(strings.ToUpper(err.Error()), "FOREIGN KEY") {
if conn, connErr := s.db.Conn(context.Background()); connErr == nil {
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF;`)
_, err = conn.ExecContext(
context.Background(),
`INSERT INTO teams (id, org_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)`,
team.ID, team.OrgID, team.Name, team.Description, team.CreatedAt,
)
_, _ = conn.ExecContext(context.Background(), `PRAGMA foreign_keys = ON;`)
_ = conn.Close()
}
}
if err != nil {
return nil, err
}
@@ -4898,7 +4730,7 @@ func (s *Store) CreateTeam(orgID, name, description string) (*domain.Team, error
func (s *Store) DeleteTeam(teamID string) error {
_, err := s.db.Exec(`DELETE FROM teams WHERE id = ?`, teamID)
s.PruneTaskAssignees()
s.pruneSharingNow()
return err
}
@@ -5078,7 +4910,8 @@ func (s *Store) RemoveTeamMember(teamID, userID string) error {
}
}
_, err = s.db.Exec(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID)
s.PruneTaskAssignees()
// Whoever leaves a group also loses the shares they made with it
s.pruneSharingNow()
return err
}
@@ -5108,7 +4941,7 @@ func (s *Store) GetTeamMembers(teamID string) ([]domain.TeamMember, error) {
// --- USER INVITES ---
func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDays int) (*domain.UserInvite, error) {
func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) {
if role != "admin" && role != "member" {
role = "member"
}
@@ -5118,19 +4951,19 @@ func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDay
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
TeamID: strings.TrimSpace(teamID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
ID: domain.NewID("inv"),
Code: code,
Email: strings.ToLower(strings.TrimSpace(email)),
Role: role,
OrganizationID: strings.TrimSpace(orgID),
CreatedBy: createdBy,
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
CreatedAt: now.Format(time.RFC3339),
}
_, err := s.db.Exec(
`INSERT INTO user_invites (id, code, email, role, team_id, created_by, expires_at, used_by, created_at)
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
inv.ID, inv.Code, inv.Email, inv.Role, inv.TeamID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
)
if err != nil {
return nil, err
@@ -5140,9 +4973,9 @@ func (s *Store) CreateUserInvite(createdBy, email, role, teamID string, validDay
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
rows, err := s.db.Query(
`SELECT i.id, i.code, i.email, i.role, i.team_id, COALESCE(t.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
`SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at
FROM user_invites i
LEFT JOIN teams t ON t.id = i.team_id
LEFT JOIN organizations o ON o.id = i.organization_id
ORDER BY i.created_at DESC`,
)
if err != nil {
@@ -5153,7 +4986,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
invites := make([]domain.UserInvite, 0)
for rows.Next() {
var inv domain.UserInvite
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.TeamName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil {
invites = append(invites, inv)
}
}
@@ -5164,10 +4997,10 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
err := s.db.QueryRow(
`SELECT id, code, email, role, team_id, created_by, expires_at, used_by, created_at
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
FROM user_invites WHERE UPPER(code) = ?`,
code,
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.TeamID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
if err != nil {
return nil, errors.New("Ungültiger Einladungscode")
}
@@ -5213,7 +5046,7 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls
func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
settings := &domain.SystemSettings{
SharingMode: "teams_only",
SharingMode: SharingOrganizations,
AllowRegistration: false,
InstanceName: "MindSift",
SMTPPort: "587",
@@ -5268,9 +5101,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
if settings.SMTPPort == "" {
settings.SMTPPort = "587"
}
if settings.SharingMode == "" {
settings.SharingMode = "teams_only"
}
settings.SharingMode = NormalizeSharingMode(settings.SharingMode)
if settings.InstanceName == "" {
settings.InstanceName = "MindSift"
}
@@ -5354,4 +5185,3 @@ func (s *Store) MarkTaskAlarmSent(taskID string) error {
_, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 1 WHERE id = ?`, taskID)
return err
}
+25 -4
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)
}
@@ -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)
}
}
+7 -1
View File
@@ -30,7 +30,13 @@ func TestTaskAssigneesFollowListAccess(t *testing.T) {
t.Fatalf("UpdateThoughtTriage failed: %v", err)
}
org, _ := store.CreateOrganization(owner.ID, "Familie")
// 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)
+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)
}
}