Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ab138606 | ||
|
|
75a7b67d09 | ||
|
|
acefa3160c | ||
|
|
1fac1a80b7 | ||
|
|
5f51637c78 | ||
|
|
a62b1893b4 | ||
|
|
a044620cec | ||
|
|
b07a7acf42 | ||
|
|
e509f685c1 | ||
|
|
d6c70d7a8e | ||
|
|
0e9bfab943 | ||
|
|
9c6598cb85 | ||
|
|
71fa53ead3 | ||
|
|
9eeb32f085 | ||
|
|
a62b204e14 | ||
|
|
e74661e0c7 | ||
|
|
633bd6f1ef | ||
|
|
a354f3d008 | ||
|
|
ce321ce579 | ||
|
|
2eff9319b5 | ||
|
|
bf0d68e7e1 | ||
|
|
84395f3322 | ||
|
|
73fad1c5db | ||
|
|
1bd38f4d50 | ||
|
|
3540b4b2ae | ||
|
|
f82ab2fd32 |
No files matched your search
@@ -17,3 +17,11 @@ SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@mindsift.local
|
||||
SMTP_TLS=true
|
||||
# Only for internal mail servers with self-signed certificates
|
||||
SMTP_SKIP_VERIFY=false
|
||||
|
||||
# Optional legal notices: external link and/or Markdown (inline or as file path)
|
||||
LEGAL_IMPRINT_URL=
|
||||
LEGAL_PRIVACY_URL=
|
||||
LEGAL_IMPRINT_CONTENT_FILE=
|
||||
LEGAL_PRIVACY_CONTENT_FILE=
|
||||
@@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||
| [ui-patterns.md](ui-patterns.md) | Einheitliche Bedienelemente: Personensuche, Profilbild-Stapel, Zähler |
|
||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||
|
||||
## Überblick in 60 Sekunden
|
||||
|
||||
@@ -7,7 +7,8 @@ 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`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
@@ -26,19 +27,47 @@ 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 | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
|
||||
| GET | `/export` | Datenexport als JSON |
|
||||
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
|
||||
### Globale Suche (`GET /api/v1/search?q=`)
|
||||
|
||||
Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||
- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge.
|
||||
- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen.
|
||||
- Titel-Treffer werden höher gewichtet als Treffer im Fließtext.
|
||||
- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und `<mark>`-Hervorhebung:
|
||||
```json
|
||||
{
|
||||
"thoughts": [...],
|
||||
"documents": [...],
|
||||
"listItems": [...],
|
||||
"hits": [
|
||||
{
|
||||
"type": "document",
|
||||
"id": "blk_123",
|
||||
"title": "Projektplanung",
|
||||
"snippet": "... wichtige Notiz zum <mark>Release</mark> ...",
|
||||
"docId": "doc_abc",
|
||||
"blockId": "blk_123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Gedanken, Triage, Aufgaben
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||
@@ -130,21 +159,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
|
||||
|
||||
@@ -64,6 +64,9 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
@@ -96,3 +99,23 @@ Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
||||
|
||||
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||
|
||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||
|
||||
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
|
||||
- **Design & Geometrie:** Sanft abgerundetes Quadrat (`rx=108` auf 512x512 Canvas bzw. `rounded-xl`), Primärblau (`--accent-primary: #2563eb`) als Hintergrund und zentriertes Lucide Brain Icon in reinem Weiß (`--text-on-accent: #ffffff`).
|
||||
|
||||
### Asset-Matrix (`frontend/public/`)
|
||||
|
||||
| Datei | Zweck & Zielplattform | Besonderheit |
|
||||
|---|---|---|
|
||||
| `icon.svg` | Modernes Standard-Icon & SVG-Favicon | Vektor-Original mit abgerundeten Ecken |
|
||||
| `icon-192x192.png`, `icon-512x512.png` | PWA-Installation (`purpose: "any"`), Desktop Linux/Windows | Erforderlich für Chromium unter Linux (erzeugt sonst kein `.desktop`-Icon) |
|
||||
| `icon-maskable-192x192.png`, `icon-maskable-512x512.png`, `icon-maskable.svg` | Android Adaptive Icons (`purpose: "maskable"`) | Full-Bleed Hintergrund (`#2563eb`) ohne transparente Ecken, Brain im 80%-Sicherheitsbereich |
|
||||
| `apple-touch-icon.png` (180x180) | iOS Safari („Zum Home-Bildschirm“) | Opaker Hintergrund verhindert schwarze Ränder beim automatischen Apple-Squircle-Maskieren |
|
||||
| `favicon.ico`, `favicon-32x32.png`, `favicon-16x16.png` | Browser-Tabs, Lesezeichen, Chronik | Multi-Resolution-ICO (16/32/48) für alle Desktop-Browser |
|
||||
| `og-image.png` (1200x630), `og-image-square.png` (512x512) | Link-Vorschau in WhatsApp, Discord, Telegram, X/Twitter | Dunkler `#0b0f19`-Hintergrund mit Icon & Claim; Datei-Größe < 120 KB (WhatsApp-Limit < 300 KB) |
|
||||
|
||||
### Dynamische OpenGraph-URL-Injektion
|
||||
|
||||
Messenger-Crawler (insbesondere WhatsApp) werten relative Bild-URLs in `og:image` oft nicht korrekt aus. Der Go-SPA-Handler in [`internal/api/server.go`](../internal/api/server.go) ersetzt beim Ausliefern von `index.html` automatisch relative Pfade (`/og-image.png`, `og:url`) durch die absolute Server-Origin (`scheme://host`), ermittelt über `X-Forwarded-Proto` und `X-Forwarded-Host` bzw. `req.Host`.
|
||||
@@ -9,9 +9,10 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
### Benutzer und Zugang
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||
|
||||
@@ -19,7 +20,8 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||
|
||||
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
||||
@@ -48,14 +50,14 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
|
||||
|---|---|
|
||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
|
||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by` (leer bei alten Einträgen), `sort_order` |
|
||||
|
||||
### Gruppen
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
|
||||
| `teams` | Gruppen der Benutzer: `name`, `description`, `org_id` (Organisation des Erstellers) |
|
||||
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
||||
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
|
||||
| `organizations` | Vom Server-Admin angelegt: `name`. Mitglieder über `users.organization_id` (höchstens eine pro Benutzer) |
|
||||
|
||||
## Beziehungen
|
||||
|
||||
@@ -77,8 +79,8 @@ teams 1─n team_members n─1 users
|
||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft.
|
||||
|
||||
## Sicherung
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -27,13 +27,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
|
||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||
| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** |
|
||||
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | 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.
|
||||
@@ -144,9 +147,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
||||
|
||||
### FLAW-18: Suche
|
||||
- **Fundstelle:** `Store.SearchAll`.
|
||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
||||
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
|
||||
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
|
||||
|
||||
### FLAW-19: Geringe Frontend-Testabdeckung
|
||||
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
||||
@@ -171,6 +173,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.
|
||||
|
||||
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
||||
## Datenmodell
|
||||
|
||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
|
||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by`, `sort_order`.
|
||||
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||
|
||||
## Funktionen
|
||||
@@ -16,4 +16,6 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
||||
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
||||
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
||||
- **Wer hat's hinzugefügt:** In geteilten Listen zeigt jeder Eintrag ein kleines Profilbild der Person, die ihn angelegt hat (`createdBy`, `createdByName`). Einträge von vor dieser Funktion haben keinen Autor und zeigen nichts.
|
||||
- **Tabs** zeigen offene Einträge als runden Zähler und die Personen mit Zugriff als Profilbild-Stapel ([ui-patterns.md](../ui-patterns.md)); die API liefert sie je Liste als `members`.
|
||||
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
||||
@@ -4,7 +4,7 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
|
||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
||||
|
||||
## Ansichten
|
||||
|
||||
@@ -21,7 +21,11 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
||||
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
||||
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
||||
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
||||
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
||||
- **Zuweisen** (nur in geteilten Listen, also mit mindestens einer Person außer dem Eigentümer): Im Detail-Fenster unter „Zugewiesen“ lassen sich eine oder mehrere Personen mit Zugriff auf die Liste zuweisen, über die einheitliche Personensuche ([ui-patterns.md](../ui-patterns.md)). Zuweisen dürfen Eigentümer und Bearbeiter. Zeilen und Kanban-Karten zeigen die Zugewiesenen als Profilbild-Stapel.
|
||||
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
|
||||
|
||||
## Alarme
|
||||
|
||||
@@ -29,10 +33,18 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
|
||||
|
||||
| Kanal | Ziel | Konfiguration |
|
||||
|---|---|---|
|
||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
|
||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung, siehe unten |
|
||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||
|
||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||
|
||||
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||
|
||||
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
|
||||
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
|
||||
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
||||
|
||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
||||
@@ -6,6 +6,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
||||
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
||||
- **Hashtag-Regel:** `#wort` zählt nur am Textanfang oder nach Leerzeichen bzw. `(`. Anker in Links (`example.com/blog#ueberschrift`) und Wörter wie `C#` sind keine Tags. Gleiche Regel im Server (`domain.ExtractTags`) und im Browser (`lib/hashtags.ts`). Beim ersten Start nach der Umstellung entfernt `migrateAnchorTags` einmalig gespeicherte Tags, die nur aus Ankern stammten.
|
||||
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
|
||||
@@ -31,6 +32,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
||||
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
||||
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
||||
- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom).
|
||||
|
||||
## Explorer, Tags, To-Dos
|
||||
|
||||
@@ -49,4 +51,15 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)).
|
||||
|
||||
## Export als Markdown-Archiv
|
||||
|
||||
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||
|
||||
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
|
||||
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
|
||||
- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“.
|
||||
- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien.
|
||||
- Ein Import aus ZIP gibt es noch nicht.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
| Ebene | Werte | Bedeutung |
|
||||
|---|---|---|
|
||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
|
||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Organisationen, Einladungen und Einstellungen (`/admin/*`). |
|
||||
| Organisation | Mitglied | Bestimmt, wer wen sieht (siehe unten). Nur Server-Admins ordnen zu. |
|
||||
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
|
||||
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
||||
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
||||
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
|
||||
- **Aufgaben-Zuweisungen** hängen am Zugriff: Verliert jemand den letzten Zugriffsweg auf eine Aufgabenliste, entfernt der Server ihn aus allen Zuweisungen dieser Liste ([modules/tasks.md](modules/tasks.md)).
|
||||
|
||||
## Aufgabenlisten teilen (Details)
|
||||
|
||||
@@ -31,17 +32,34 @@
|
||||
|
||||
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
||||
|
||||
## Isolations-Modi (`system_settings.sharing_mode`)
|
||||
## Sichtbarkeit: Organisationen und Modi (`system_settings.sharing_mode`)
|
||||
|
||||
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
||||
Zwei Begriffe, nicht verwechseln:
|
||||
|
||||
| Modus | Zweck |
|
||||
| Begriff | Verwaltet von | Zweck |
|
||||
|---|---|---|
|
||||
| **Organisation** | nur Server-Admin (`/admin/organizations`) | Legt fest, wer wen **sehen** darf. Jeder Benutzer gehört höchstens einer an (`users.organization_id`). |
|
||||
| **Gruppe** (Code: `teams`) | Benutzer selbst | Abkürzung beim Teilen („teile mit meiner WG“). Erweitert die Sichtbarkeit nie. |
|
||||
|
||||
| Modus | Wer sieht wen |
|
||||
|---|---|
|
||||
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
|
||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
||||
| `open` | Jeder kann für jeden freigeben |
|
||||
| `private` | Niemand. Benutzersuche, Teilen und Gruppen sind aus (Server antwortet mit Fehler, UI blendet sie aus). |
|
||||
| `organizations` (Standard) | Nur Mitglieder derselben Organisation. Wer keiner angehört, sieht niemanden. |
|
||||
| `open` | Alle. |
|
||||
|
||||
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
|
||||
**Eine Regel für alles:** `Store.CanSee(a, b)` und `Store.CanShareWith(owner, typ, ziel)` in [visibility.go](../internal/storage/visibility.go). Darüber laufen Benutzerverzeichnis (`/users/directory`, `/collaborators`), jede Freigabe (Notiz, Ordner, Aufgabenliste, Liste) und das Hinzufügen zu Gruppen. Server-Admins folgen beim Teilen derselben Regel; alle Konten verwalten sie über `/admin/*`.
|
||||
|
||||
- Mit einer Gruppe teilen darf nur, wer selbst Mitglied ist. Eine Gruppe gehört zur Organisation ihres Erstellers (`teams.org_id`), Mitglieder kommen nur von dort.
|
||||
- Unbekannte und nicht sichtbare Personen liefern dieselbe Meldung („Benutzer oder Gruppe nicht gefunden“), damit niemand Konten per E-Mail ausprobieren kann.
|
||||
- **Teilen geht nur vom Eigentümer.** Bearbeiter können nicht weiterteilen und keine fremden Freigaben entfernen; jeder kann aber seine eigene Freigabe entfernen (eine geteilte Notiz oder einen Ordner verlassen).
|
||||
|
||||
**Rechte folgen der Sichtbarkeit.** `Store.PruneSharing` entfernt alles, was unter dem aktuellen Modus nicht mehr erlaubt wäre: Gruppen-Mitgliedschaften außerhalb der Organisation der Gruppe, Freigaben an Personen, die der Eigentümer nicht mehr sieht, Freigaben an Gruppen, in denen der Eigentümer nicht mehr ist; danach leere Gruppen und über `PruneTaskAssignees` die Aufgaben-Zuweisungen. Gruppen ohne Admin bekommen einen. Es läuft nach:
|
||||
|
||||
- Wechsel oder Entfernen der Organisation eines Benutzers, Löschen einer Organisation
|
||||
- Wechsel des Modus durch den Admin (vorher zeigt `GET /admin/settings/sharing-impact?mode=…` als Probelauf, wie viel wegfällt)
|
||||
- Austritt aus oder Löschen einer Gruppe, Löschen eines Kontos
|
||||
|
||||
Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Namen der Instanz angelegt und der erste Admin ihr zugeordnet. Einladungen und „Benutzer anlegen“ können eine Organisation mitgeben. Im Demo-Modus sind `admin` und `user` in der Organisation „Demo“.
|
||||
|
||||
## Entkoppelte Ablage geteilter Notizen und Ordner
|
||||
|
||||
@@ -52,4 +70,4 @@ Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
|
||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
|
||||
@@ -97,11 +97,31 @@ In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js
|
||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
||||
|
||||
### 🏷️ App-Icon & Branding
|
||||
Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-Icons und Social-Preview-Bilder) folgt der primären Akzentfarbe `--accent: #2563eb` und `--text-on-accent: #ffffff` mit den typischen leichten Eckenrundungen (`rounded-xl` bzw. `rx=108`). Details zur Asset-Matrix und den Icon-Formaten siehe [architecture.md](architecture.md#app-icons-pwa--social-preview-open-graph).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
||||
|
||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
||||
### Ohne Rebuild: Theme-Manager in den Kontoeinstellungen
|
||||
|
||||
Unter **Kontoeinstellungen → Darstellung & Themes** kann jeder Benutzer eigene Themes anlegen:
|
||||
|
||||
- **Vorlage** herunterladen, kopieren oder direkt ins Eingabefeld einfügen (`theme-template.css`).
|
||||
- CSS als **`.css`-Datei hochladen** oder **einfügen**, Namen vergeben, „Speichern & aktivieren“.
|
||||
- Eigene Themes erscheinen als Kacheln in der Galerie und im Profil-Dropdown, mit **Bearbeiten** und **Löschen**.
|
||||
|
||||
Technik:
|
||||
|
||||
- Gespeichert pro Benutzer in der Tabelle `user_themes` (API `GET/POST /themes`, `DELETE /themes/{id}`, max. 200 KB), also im Daten-Volume und auf allen Geräten verfügbar.
|
||||
- Aktiv gesetzt wird ein eigenes Theme als `custom:<id>`. [`theme.ts`](../frontend/src/lib/theme.ts) setzt das CSS live als `<style id="mindsift-custom-theme">` ans Ende von `<head>` (gewinnt damit gegen die eingebauten Themes) und übernimmt den Wert aus dem Selektor `[data-theme="…"]` des CSS für `<html data-theme>`. Ohne solchen Selektor gilt das CSS für `:root`. Kommentare werden dabei ignoriert.
|
||||
- Ob die Tailwind-Klasse `dark` gesetzt wird, entscheidet die Helligkeit von `--bg-app`.
|
||||
- Das CSS des aktiven Themes liegt zusätzlich im `localStorage`, damit es beim Start sofort greift; nach dem Login wird es mit dem Server abgeglichen. Wird es gelöscht, fällt die Ansicht auf „Dark“ zurück.
|
||||
|
||||
### Eingebautes Theme (mit Rebuild)
|
||||
|
||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") fest in MindSift einzubauen:
|
||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||
3. Passe Farben und Rundungen nach Belieben an.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# UI-Muster
|
||||
|
||||
Wiederkehrende Bedienelemente, die überall gleich aussehen und sich gleich verhalten. Wer eines davon neu braucht, nimmt die vorhandene Komponente und baut keine eigene Variante.
|
||||
|
||||
## Personen suchen (Type-to-Search)
|
||||
|
||||
Komponente: [UserAutocomplete.tsx](../frontend/src/features/UserAutocomplete.tsx)
|
||||
|
||||
Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`GroupsView`) und beim Zuweisen von Aufgaben (`TaskDetailModal`).
|
||||
|
||||
- **Keine Liste vorab:** Solange das Feld leer ist, erscheint nichts, auch nicht beim Reinklicken. Kein Dropdown mit allen Personen.
|
||||
- **Erst beim Tippen** erscheinen Treffer direkt unter dem Feld, **höchstens 3** (`MAX_RESULTS`). Gesucht wird in Anzeigename, `@Benutzername` und E-Mail.
|
||||
- Der **erste Treffer ist vorausgewählt**: Tippen + Enter übernimmt ihn. Pfeiltasten wechseln, Escape schließt.
|
||||
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
|
||||
- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×.
|
||||
- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei.
|
||||
|
||||
## Profilbilder als Stapel
|
||||
|
||||
Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt `UserAvatar`)
|
||||
|
||||
- Kleine runde Profilbilder, **leicht überlappend**, **höchstens 3**. Weitere Personen erscheinen als monochromer Kreis „+n“.
|
||||
- Ohne Profilbild zeigt `UserAvatar` die Initialen.
|
||||
- `ringClass` bekommt die Farbe der Fläche dahinter (meist `ring-[var(--bg-panel)]` oder `ring-[var(--bg-app)]`), damit die Kreise sauber getrennt sind.
|
||||
- Größen: `xxs` (16 px) in Tabs, Kanban-Karten und Listeneinträgen, `xs` (20 px) in Aufgabenzeilen und Kopfzeilen.
|
||||
- **Kein Hover-Effekt** im Stapel selbst. Einzige Ausnahme ist die Anwesenheit in Notizen ([PresenceAvatars.tsx](../frontend/src/features/workspace/PresenceAvatars.tsx)): Dort zeigt Hover oder Tastaturfokus die Liste „Gerade online“.
|
||||
|
||||
| Ort | Wer wird gezeigt |
|
||||
|---|---|
|
||||
| Notiz-Kopfzeile | wer die Notiz gerade geöffnet hat (Profilbilder aus `/users/directory`) |
|
||||
| Tabs der Aufgabenlisten und Listen | alle mit Zugriff außer mir (`members`) |
|
||||
| Aufgabenzeile und Kanban-Karte | zugewiesene Personen (nur in geteilten Listen) |
|
||||
| Eintrag einer geteilten Liste | wer ihn hinzugefügt hat (`createdBy`); ältere Einträge ohne Autor zeigen nichts |
|
||||
|
||||
## Schalter
|
||||
|
||||
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||
|
||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
||||
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
|
||||
|
||||
## Zähler
|
||||
|
||||
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
|
||||
|
||||
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
|
||||
- **Farbe (`variant`):**
|
||||
- `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben).
|
||||
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
|
||||
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
|
||||
- **Position (`position`):**
|
||||
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
|
||||
- `inline`: im Textfluss.
|
||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#-schnellstart)
|
||||
[](#-schnellstart)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
@@ -128,7 +128,7 @@ Minimal:
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.2); :latest = main, :dev = Entwicklungsstand
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.3); :latest = main, :dev = Entwicklungsstand
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
@@ -156,6 +156,9 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||
|
||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
@@ -182,7 +185,7 @@ npm run test:run # Komponententests (Vitest, jsdom)
|
||||
npm run build # Produktions-Build
|
||||
cd ..
|
||||
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||
APP_VERSION=v0.2 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
||||
APP_VERSION=v0.3 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
||||
```
|
||||
|
||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||
@@ -200,13 +203,13 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
||||
MindSift ist gemeinfrei unter [The Unlicense](LICENSE): Du darfst den Code ohne jede Bedingung nutzen, ändern, weitergeben und verkaufen, auch ohne Namensnennung. Es gibt keine Gewährleistung.
|
||||
|
||||
<div align="center">
|
||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
|
||||
@@ -37,18 +37,18 @@ func main() {
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
|
||||
@@ -26,3 +26,9 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||
@@ -26,3 +26,9 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_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
|
||||
|
||||
@@ -69,8 +69,7 @@ const main = async () => {
|
||||
await search.click();
|
||||
await search.type('user');
|
||||
await sleep(300);
|
||||
await search.press('ArrowDown');
|
||||
await search.press('Enter');
|
||||
await search.press('Enter'); // the first match is preselected
|
||||
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
||||
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
||||
await roleSelect.selectOption('editor');
|
||||
|
||||
@@ -9,9 +9,35 @@
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Favicon & Touch Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Open Graph / WhatsApp / Discord / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="MindSift" />
|
||||
<meta property="og:url" content="/" />
|
||||
<meta property="og:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||
<meta property="og:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<meta property="og:image:secure_url" content="/og-image.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="MindSift App Logo & Vorschau" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||
<meta name="twitter:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="MindSift App Logo & Vorschau" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "^4.12.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -2101,6 +2102,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz",
|
||||
"integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -2302,6 +2318,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "4.12.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#2563eb"/>
|
||||
<g transform="translate(256, 256) scale(11) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 861 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="512" height="512" rx="108" fill="#2563eb"/>
|
||||
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 872 B |
@@ -17,8 +17,32 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckSquare,
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
||||
import {
|
||||
api,
|
||||
clearToken,
|
||||
@@ -32,14 +33,18 @@ import {
|
||||
triggerHaptic,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
isSharingDisabled,
|
||||
} from './lib/api';
|
||||
import {
|
||||
AVAILABLE_THEMES,
|
||||
customThemeOption,
|
||||
initThemeOnBoot,
|
||||
isCustomThemeId,
|
||||
setActiveTheme as applyTheme,
|
||||
ThemeId,
|
||||
} from './lib/theme';
|
||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||
import { CountBadge } from './components/CountBadge';
|
||||
import { TriageDeck } from './features/triage/TriageDeck';
|
||||
import { TasksView } from './features/triage/TasksView';
|
||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
@@ -52,6 +57,7 @@ import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
@@ -114,6 +120,8 @@ export const App: React.FC = () => {
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
||||
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
inboxCount: 0,
|
||||
tasksOpenCount: 0,
|
||||
@@ -130,6 +138,8 @@ export const App: React.FC = () => {
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -226,7 +236,7 @@ export const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
closeMenu();
|
||||
if (activeTab === 'triage') {
|
||||
setPrimaryAction(() => openMobileTriageCapture);
|
||||
setPrimaryAction(openMobileTriageCapture);
|
||||
setGroups([
|
||||
{
|
||||
id: 'triage-actions',
|
||||
@@ -273,12 +283,16 @@ export const App: React.FC = () => {
|
||||
badge: stats.listsCount,
|
||||
onClick: () => setActiveTab('lists'),
|
||||
},
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Teams & Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
...(isSharingDisabled()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -411,12 +425,33 @@ export const App: React.FC = () => {
|
||||
setShowProfileMenu(false);
|
||||
};
|
||||
|
||||
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
|
||||
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
|
||||
applyTheme(themeId, css);
|
||||
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
|
||||
};
|
||||
|
||||
const handleChangeTheme = (themeId: ThemeId) => {
|
||||
triggerHaptic(10);
|
||||
applyTheme(themeId);
|
||||
setActiveThemeState(themeId);
|
||||
activateTheme(themeId, customThemes);
|
||||
};
|
||||
|
||||
// Custom themes live on the server; after login refresh them and the active one (it may have
|
||||
// been edited or deleted on another device)
|
||||
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
|
||||
setCustomThemes(themes);
|
||||
const next = activate ?? activeTheme;
|
||||
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
api
|
||||
.getThemes()
|
||||
.then((themes) => handleCustomThemesChange(themes))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
try {
|
||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||
@@ -563,6 +598,36 @@ export const App: React.FC = () => {
|
||||
setActiveTab('tasks');
|
||||
}, []);
|
||||
|
||||
const handleOpenList = (listId: string) => {
|
||||
setSelectedListId(listId);
|
||||
setActiveTab('lists');
|
||||
};
|
||||
|
||||
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditing && e.key === '/') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
@@ -618,7 +683,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',
|
||||
@@ -679,6 +744,19 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCommandPalette(true);
|
||||
}}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
||||
title="Globale Suche"
|
||||
aria-label="Globale Suche öffnen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -719,12 +797,23 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommandPalette(true)}
|
||||
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche öffnen (Cmd+K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
@@ -765,7 +854,7 @@ export const App: React.FC = () => {
|
||||
<span>Design-Theme</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{AVAILABLE_THEMES.map((t) => (
|
||||
{allThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
@@ -802,6 +891,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -815,6 +905,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleOpenHelp}
|
||||
@@ -873,11 +964,7 @@ export const App: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Triage</span>
|
||||
{stats.inboxCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -891,11 +978,7 @@ export const App: React.FC = () => {
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -909,11 +992,7 @@ export const App: React.FC = () => {
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -970,6 +1049,7 @@ export const App: React.FC = () => {
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
initialSelectedTaskId={selectedTaskId}
|
||||
openTasksCount={stats.tasksOpenCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -988,7 +1068,11 @@ export const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
<SharedListsView
|
||||
onRefreshGlobal={refreshAll}
|
||||
currentUser={user}
|
||||
initialListId={selectedListId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
@@ -1002,7 +1086,10 @@ export const App: React.FC = () => {
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
activeTheme={activeTheme}
|
||||
themes={allThemes}
|
||||
customThemes={customThemes}
|
||||
onThemeChange={handleChangeTheme}
|
||||
onCustomThemesChange={handleCustomThemesChange}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
@@ -1037,11 +1124,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<Layers className="w-5 h-5" />
|
||||
{stats.inboxCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Triage</span>
|
||||
</button>
|
||||
@@ -1060,11 +1143,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
@@ -1102,11 +1181,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
</button>
|
||||
@@ -1313,7 +1388,7 @@ export const App: React.FC = () => {
|
||||
<span>Design-Theme</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_THEMES.map((t) => (
|
||||
{allThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
@@ -1367,6 +1442,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1381,6 +1457,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -1518,6 +1595,15 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
||||
<CommandPalette
|
||||
isOpen={showCommandPalette}
|
||||
onClose={() => setShowCommandPalette(false)}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenList={handleOpenList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Brain, ExternalLink } from 'lucide-react';
|
||||
import { APP_VERSION } from '../version';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
||||
export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||
export const PROJECT_LICENSE = 'The Unlicense – gemeinfrei, ohne Bedingungen';
|
||||
|
||||
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||
<div className="space-y-4 text-xs text-muted leading-relaxed">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||
<Brain className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-main">MindSift</p>
|
||||
<p>Der Kopf frei in drei Sekunden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '}
|
||||
<strong className="text-main">Erfassen</strong> und <strong className="text-main">Organisieren</strong>: Gedanken
|
||||
landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft
|
||||
selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir.
|
||||
</p>
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||
<dt className="font-semibold text-main">Version</dt>
|
||||
<dd className="font-mono">{APP_VERSION}</dd>
|
||||
<dt className="font-semibold text-main">Lizenz</dt>
|
||||
<dd>{PROJECT_LICENSE}</dd>
|
||||
<dt className="font-semibold text-main">Quellcode</dt>
|
||||
<dd>
|
||||
<a href={PROJECT_REPO_URL} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||
{PROJECT_REPO_URL.replace(/^https:\/\//, '')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="text-[11px] text-faint">
|
||||
Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite.
|
||||
</p>
|
||||
</div>
|
||||
</ReadingModal>
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
export interface AvatarPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface AvatarStackProps {
|
||||
people: AvatarPerson[];
|
||||
max?: number;
|
||||
size?: 'xxs' | 'xs';
|
||||
// Ring in the color of the surface behind the stack, so overlapping circles stay separated
|
||||
ringClass?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OVERFLOW_SIZE = {
|
||||
xxs: 'w-4 h-4 text-[7px]',
|
||||
xs: 'w-5 h-5 text-[9px]',
|
||||
};
|
||||
|
||||
// Small overlapping profile pictures: at most `max` circles, the rest as a monochrome "+n" circle.
|
||||
// Used for presence in notes, list/task-list tabs, task assignees and list item authors (Doku/ui-patterns.md).
|
||||
export const AvatarStack: React.FC<AvatarStackProps> = ({
|
||||
people,
|
||||
max = 3,
|
||||
size = 'xs',
|
||||
ringClass = 'ring-[var(--bg-app)]',
|
||||
title,
|
||||
className = '',
|
||||
}) => {
|
||||
if (people.length === 0) return null;
|
||||
const visible = people.slice(0, max);
|
||||
const hidden = people.length - visible.length;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center -space-x-1.5 shrink-0 ${className}`} title={title}>
|
||||
{visible.map((p) => (
|
||||
<UserAvatar
|
||||
key={p.id}
|
||||
name={p.name}
|
||||
avatarUrl={p.avatarUrl || ''}
|
||||
size={size}
|
||||
showTitle={false}
|
||||
className={`ring-2 ${ringClass}`}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<span
|
||||
className={`relative inline-flex items-center justify-center rounded-full font-bold leading-none bg-muted text-panel ring-2 ${ringClass} ${OVERFLOW_SIZE[size]}`}
|
||||
>
|
||||
+{hidden}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
ShoppingCart,
|
||||
X,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
} from 'lucide-react';
|
||||
import { SearchHit } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
onOpenList: (listId: string) => void;
|
||||
}
|
||||
|
||||
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!snippet) return [];
|
||||
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||
const text = part.slice(6, -7);
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||
>
|
||||
{text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenDocument,
|
||||
onOpenTask,
|
||||
onOpenList,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Group hits by category: Notizen, Aufgaben, Listen
|
||||
const groupedHits = useMemo(() => {
|
||||
const docs: SearchHit[] = [];
|
||||
const tasks: SearchHit[] = [];
|
||||
const lists: SearchHit[] = [];
|
||||
|
||||
for (const h of hits) {
|
||||
if (h.type === 'document') {
|
||||
docs.push(h);
|
||||
} else if (h.type === 'thought') {
|
||||
tasks.push(h);
|
||||
} else if (h.type === 'list_item') {
|
||||
lists.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
||||
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
|
||||
].filter((g) => g.items.length > 0);
|
||||
}, [hits]);
|
||||
|
||||
// Flattened array for simple up/down index navigation
|
||||
const flatHits = useMemo(() => {
|
||||
return groupedHits.flatMap((g) => g.items);
|
||||
}, [groupedHits]);
|
||||
|
||||
// Focus input and reset on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
setSelectedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 150ms debounce search
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
debounceTimerRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.searchAll(trimmed);
|
||||
setHits(res.hits || []);
|
||||
setSelectedIndex(0);
|
||||
} catch {
|
||||
setHits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Keyboard navigation: ↑, ↓, Enter, Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (flatHits.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % flatHits.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selected = flatHits[selectedIndex];
|
||||
if (selected) {
|
||||
executeSelection(selected);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, flatHits, selectedIndex]);
|
||||
|
||||
const executeSelection = (hit: SearchHit) => {
|
||||
onClose();
|
||||
if (hit.type === 'document') {
|
||||
onOpenDocument(hit.docId || hit.id, hit.blockId);
|
||||
} else if (hit.type === 'thought') {
|
||||
onOpenTask(hit.id);
|
||||
} else if (hit.type === 'list_item') {
|
||||
if (hit.listId) {
|
||||
onOpenList(hit.listId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Search Modal Container */}
|
||||
<div
|
||||
className="
|
||||
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
||||
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
||||
"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Input Bar */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
||||
<Search className="w-5 h-5 text-accent shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||
/>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
||||
{query && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="text-muted hover:text-main p-1"
|
||||
title="Eingabe leeren"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results Container */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||
{query.trim() === '' ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
||||
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
||||
</div>
|
||||
) : flatHits.length === 0 && !loading ? (
|
||||
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
||||
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
||||
</div>
|
||||
) : (
|
||||
groupedHits.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<div key={group.key} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
||||
<GroupIcon className="w-3.5 h-3.5" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const overallIndex = flatHits.indexOf(item);
|
||||
const isSelected = overallIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => executeSelection(item)}
|
||||
onMouseEnter={() => setSelectedIndex(overallIndex)}
|
||||
className={`
|
||||
px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border
|
||||
${
|
||||
isSelected
|
||||
? 'bg-panel-elevated border-accent text-main'
|
||||
: 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-xs truncate">
|
||||
{item.title || 'Unbenannt'}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
||||
<span>Öffnen</span>
|
||||
<CornerDownLeft className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.snippet && (
|
||||
<div>
|
||||
<SnippetRenderer snippet={item.snippet} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Shortcut Bar (Desktop) */}
|
||||
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{flatHits.length} Treffer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CountBadgeProps {
|
||||
count: number | undefined | null;
|
||||
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
|
||||
variant?: 'accent' | 'muted';
|
||||
// corner: notification badge on the top right of the nearest `relative` parent
|
||||
position?: 'inline' | 'corner';
|
||||
showZero?: boolean;
|
||||
className?: string; // e.g. to fine-tune the corner offset
|
||||
}
|
||||
|
||||
// Full class names: Tailwind drops component classes it cannot find literally in the source
|
||||
const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' };
|
||||
|
||||
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
|
||||
export const CountBadge: React.FC<CountBadgeProps> = ({
|
||||
count,
|
||||
variant = 'muted',
|
||||
position = 'inline',
|
||||
showZero = false,
|
||||
className = '',
|
||||
}) => {
|
||||
if (count === undefined || count === null || (count <= 0 && !showZero)) return null;
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
return (
|
||||
<span
|
||||
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`}
|
||||
aria-label={String(count)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
type LegalType = 'imprint' | 'privacy';
|
||||
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
|
||||
interface LegalLinksProps {
|
||||
legal?: AuthStatus['legal'];
|
||||
className?: string;
|
||||
children?: React.ReactNode; // further footer links, e.g. "Über MindSift"
|
||||
}
|
||||
|
||||
// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal.
|
||||
// Links without URL or text are not shown at all.
|
||||
export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', children }) => {
|
||||
const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const entries = (['imprint', 'privacy'] as LegalType[])
|
||||
.map((type) => ({
|
||||
type,
|
||||
url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl,
|
||||
hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent,
|
||||
}))
|
||||
.filter((e) => e.url || e.hasContent);
|
||||
|
||||
if (entries.length === 0 && !children) return null;
|
||||
|
||||
const openText = async (type: LegalType) => {
|
||||
setError(null);
|
||||
try {
|
||||
setOpen({ type, content: await api.getLegalText(type) });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Text konnte nicht geladen werden');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
<button key={e.type} type="button" onClick={() => openText(e.type)} className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{error && <span className="text-status-err">{error}</span>}
|
||||
{open && (
|
||||
<ReadingModal title={TITLES[open.type]} onClose={() => setOpen(null)}>
|
||||
<MarkdownViewer content={open.content} />
|
||||
</ReadingModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useMobileActions } from '../context/MobileActionContext';
|
||||
import { triggerHaptic } from '../lib/api';
|
||||
import { CountBadge } from './CountBadge';
|
||||
|
||||
interface MobileHeaderMenuProps {
|
||||
activeTabLabel: string;
|
||||
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.badge !== undefined && item.badge !== '' && (
|
||||
{typeof item.badge === 'number' && !isAccent ? (
|
||||
<CountBadge count={item.badge} showZero variant={isActive ? 'accent' : 'muted'} />
|
||||
) : item.badge !== undefined && item.badge !== '' && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||
isAccent
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ReadingModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it
|
||||
export const ReadingModal: React.FC<ReadingModalProps> = ({ title, onClose, children }) => {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-2xl max-h-[85vh] flex flex-col shadow-panel">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-main">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="btn-icon" title="Schließen">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-sm text-main">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
|
||||
} | null;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
alt?: string;
|
||||
showTitle?: boolean; // native name tooltip on hover
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
xxs: 'w-4 h-4 text-[7px]',
|
||||
xs: 'w-5 h-5 text-[10px]',
|
||||
sm: 'w-7 h-7 text-xs',
|
||||
md: 'w-9 h-9 text-sm',
|
||||
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
size = 'md',
|
||||
className = '',
|
||||
alt,
|
||||
showTitle = true,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
||||
title={effectiveName || alt}
|
||||
title={showTitle ? effectiveName || alt : undefined}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { CountBadge } from '../CountBadge';
|
||||
|
||||
describe('CountBadge', () => {
|
||||
it('hides itself at zero unless asked', () => {
|
||||
expect(render(<CountBadge count={0} />).container.firstChild).toBeNull();
|
||||
expect(render(<CountBadge count={undefined} />).container.firstChild).toBeNull();
|
||||
expect(render(<CountBadge count={0} showZero />).container.textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('caps large numbers and keeps the real value for screen readers', () => {
|
||||
const el = render(<CountBadge count={150} />).container.firstChild as HTMLElement;
|
||||
expect(el.textContent).toBe('99+');
|
||||
expect(el.getAttribute('aria-label')).toBe('150');
|
||||
});
|
||||
|
||||
it('maps variant and position to the shared classes', () => {
|
||||
const el = render(<CountBadge count={3} variant="accent" position="corner" />).container.firstChild as HTMLElement;
|
||||
expect(el.className).toContain('count-badge-accent');
|
||||
expect(el.className).toContain('count-badge-corner');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
||||
// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until
|
||||
// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md.
|
||||
const MAX_RESULTS = 3;
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
.slice(0, MAX_RESULTS);
|
||||
}, [users, query, excludeUserIds]);
|
||||
const showResults = isOpen && query.trim() !== '';
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
if (!showResults) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
// First match is preselected so Enter picks it right away
|
||||
setHighlightedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{showResults && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
|
||||
@@ -11,8 +11,10 @@ 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';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
@@ -29,9 +31,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 +241,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 +287,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 +297,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>
|
||||
|
||||
@@ -340,6 +331,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -584,6 +576,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
currentUser?: User | null;
|
||||
initialListId?: string | null;
|
||||
}
|
||||
|
||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||
@@ -77,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [activeListId, setActiveListId] = useState<string>('');
|
||||
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (initialListId) {
|
||||
setActiveListId(initialListId);
|
||||
}
|
||||
}, [initialListId]);
|
||||
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
@@ -126,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
items: deduplicateItems(l.items || []),
|
||||
}));
|
||||
setLists(deduplicated);
|
||||
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||
setActiveListId(initialListId);
|
||||
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||
setActiveListId(deduplicated[0].id);
|
||||
}
|
||||
} catch {
|
||||
@@ -144,10 +155,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// People shown in tabs: everyone with access except me
|
||||
const otherMembers = (lst: SharedList): AvatarPerson[] =>
|
||||
(lst.members || [])
|
||||
.filter((m) => m.userId !== currentUser?.id)
|
||||
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
|
||||
|
||||
const activeList = useMemo(() => {
|
||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||
}, [lists, activeListId]);
|
||||
|
||||
// In shared lists every item shows who added it (older items without an author show nothing)
|
||||
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
|
||||
const renderItemAuthor = (item: ListItem) => {
|
||||
if (!isActiveListShared || !item.createdBy) return null;
|
||||
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
|
||||
const name = member?.displayName || member?.email || item.createdByName || '';
|
||||
return (
|
||||
<AvatarStack
|
||||
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
|
||||
size="xxs"
|
||||
ringClass="ring-transparent"
|
||||
title={name ? `Hinzugefügt von ${name}` : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeList?.id) return;
|
||||
|
||||
@@ -825,26 +858,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||
const isSelected = activeList?.id === lst.id;
|
||||
return (
|
||||
<div key={lst.id} className="relative group flex items-center">
|
||||
<div
|
||||
key={lst.id}
|
||||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveListId(lst.id)}
|
||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
} ${isSelected && lists.length > 1 ? 'rounded-l-btn rounded-r-none border-r-0' : 'rounded-btn'}`}
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
{openCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||
)}
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
|
||||
{lst.title}
|
||||
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
<AvatarStack
|
||||
people={otherMembers(lst)}
|
||||
size="xxs"
|
||||
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
</button>
|
||||
{isSelected && lists.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteList(lst.id)}
|
||||
title="Liste löschen"
|
||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -1266,6 +1307,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderItemAuthor(item)}
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
@@ -1374,6 +1416,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{renderItemAuthor(item)}
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
)}
|
||||
|
||||
@@ -13,22 +13,48 @@ 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';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
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 +70,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 +92,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
sharingMode: 'organizations',
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
smtpHost: '',
|
||||
@@ -63,6 +101,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpPass: '',
|
||||
smtpFrom: '',
|
||||
smtpTls: true,
|
||||
smtpSkipVerify: false,
|
||||
smtpConfiguredViaEnv: false,
|
||||
});
|
||||
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
|
||||
@@ -80,9 +119,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
setInvites(invList);
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]);
|
||||
setOrganizations(orgs);
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'teams_only',
|
||||
sharingMode: sys.sharingMode || 'organizations',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
@@ -91,6 +131,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpPass: sys.smtpPass || '',
|
||||
smtpFrom: sys.smtpFrom || '',
|
||||
smtpTls: sys.smtpTls ?? true,
|
||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||
});
|
||||
}
|
||||
@@ -116,8 +157,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 +179,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 +266,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 +353,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 +452,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 +523,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 +572,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 +612,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 +637,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 +687,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 +745,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 +767,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 +817,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 +868,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>
|
||||
<CountBadge count={o.memberCount} showZero />
|
||||
</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 +1003,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 +1040,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 +1095,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.allowRegistration}
|
||||
onClick={() =>
|
||||
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||
}`}
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,20 +1205,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pt-5">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpTls ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
|
||||
}
|
||||
className="w-4 h-4 checkbox-square cursor-pointer"
|
||||
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
||||
/>
|
||||
<span className="text-main font-medium">
|
||||
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
||||
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpSkipVerify ?? false}
|
||||
disabled={!(settings.smtpTls ?? true)}
|
||||
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,16 +17,24 @@ import {
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
activeTheme: ThemeId;
|
||||
themes: ThemeOption[]; // built-in + custom
|
||||
customThemes: UserTheme[];
|
||||
onThemeChange: (themeId: ThemeId) => void;
|
||||
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -36,11 +44,102 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
currentUser,
|
||||
activeTheme,
|
||||
themes,
|
||||
customThemes,
|
||||
onThemeChange,
|
||||
onCustomThemesChange,
|
||||
onUserUpdated,
|
||||
onBack,
|
||||
}) => {
|
||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- Custom Theme Editor State ---
|
||||
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
|
||||
const [themeName, setThemeName] = useState('');
|
||||
const [themeCss, setThemeCss] = useState('');
|
||||
const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
const [deletingThemeId, setDeletingThemeId] = useState<string | null>(null);
|
||||
const themeFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const resetThemeEditor = () => {
|
||||
setThemeEditId(null);
|
||||
setThemeName('');
|
||||
setThemeCss('');
|
||||
};
|
||||
|
||||
const handleThemeFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setThemeCss(text);
|
||||
if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, ''));
|
||||
setThemeMsg(null);
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'theme-template.css';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCopyTemplate = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(themeTemplateCss);
|
||||
setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' });
|
||||
} catch {
|
||||
setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' });
|
||||
}
|
||||
};
|
||||
|
||||
// Saving activates the theme right away (injected live, no reload)
|
||||
const handleSaveTheme = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSavingTheme(true);
|
||||
setThemeMsg(null);
|
||||
try {
|
||||
const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss });
|
||||
const next = themeEditId
|
||||
? customThemes.map((t) => (t.id === saved.id ? saved : t))
|
||||
: [...customThemes, saved];
|
||||
onCustomThemesChange(next, customThemeId(saved));
|
||||
setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` });
|
||||
resetThemeEditor();
|
||||
} catch (err: any) {
|
||||
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' });
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTheme = (t: UserTheme) => {
|
||||
setThemeEditId(t.id);
|
||||
setThemeName(t.name);
|
||||
setThemeCss(t.css);
|
||||
setThemeMsg(null);
|
||||
};
|
||||
|
||||
const handleDeleteTheme = async (t: UserTheme) => {
|
||||
try {
|
||||
await api.deleteTheme(t.id);
|
||||
setDeletingThemeId(null);
|
||||
if (themeEditId === t.id) resetThemeEditor();
|
||||
// Deleting the active theme falls back to the default one
|
||||
onCustomThemesChange(
|
||||
customThemes.filter((x) => x.id !== t.id),
|
||||
activeTheme === customThemeId(t) ? 'dark' : undefined
|
||||
);
|
||||
} catch (err: any) {
|
||||
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- Profile State ---
|
||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||
@@ -263,17 +362,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setTimeout(() => setCopiedLabel(null), 2000);
|
||||
};
|
||||
|
||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
const data = await api.exportAll();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
};
|
||||
|
||||
const [exportingVault, setExportingVault] = useState(false);
|
||||
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||
const handleExportVault = async () => {
|
||||
setExportingVault(true);
|
||||
setVaultError(null);
|
||||
try {
|
||||
saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
} catch (err: any) {
|
||||
setVaultError(err.message || 'Export fehlgeschlagen');
|
||||
} finally {
|
||||
setExportingVault(false);
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
@@ -591,14 +711,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{AVAILABLE_THEMES.map((t) => {
|
||||
{themes.map((t) => {
|
||||
const isSelected = activeTheme === t.id;
|
||||
const custom = customThemes.find((c) => customThemeId(c) === t.id);
|
||||
return (
|
||||
<div key={t.id} className="flex flex-col">
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onThemeChange(t.id)}
|
||||
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
||||
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
||||
isSelected
|
||||
? 'bg-canvas border-accent shadow-sm'
|
||||
: 'bg-canvas border-border hover:border-strong'
|
||||
@@ -654,23 +775,130 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<code className="text-[10px] font-mono text-faint">{t.file}</code>
|
||||
</div>
|
||||
</button>
|
||||
{custom && (
|
||||
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
|
||||
{deletingThemeId === custom.id ? (
|
||||
<>
|
||||
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
|
||||
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
|
||||
Löschen
|
||||
</button>
|
||||
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingThemeId(custom.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Theme löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
|
||||
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
|
||||
{/* Custom theme: upload or paste CSS, based on the template */}
|
||||
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-semibold text-main">
|
||||
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
<span>Vorlage herunterladen</span>
|
||||
</button>
|
||||
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Vorlage kopieren</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThemeCss(themeTemplateCss)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
>
|
||||
Vorlage einfügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted text-[11px] leading-relaxed">
|
||||
Alle Theme-Dateien liegen unter{' '}
|
||||
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
|
||||
Kopiervorlage{' '}
|
||||
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
|
||||
Eigenschaften in 6 Blöcken gruppiert: <strong>1. Akzentfarbe</strong>,{' '}
|
||||
<strong>2. Oberflächen</strong>, <strong>3. Textfarben</strong>,{' '}
|
||||
<strong>4. Rahmen & Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
|
||||
<strong>6. Rundungen</strong>.
|
||||
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
|
||||
<strong>3. Textfarben</strong>, <strong>4. Rahmen & Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
|
||||
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
|
||||
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={themeName}
|
||||
onChange={(e) => setThemeName(e.target.value)}
|
||||
placeholder="Name, z. B. Wald"
|
||||
maxLength={60}
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => themeFileRef.current?.click()}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>CSS-Datei hochladen</span>
|
||||
</button>
|
||||
<input
|
||||
ref={themeFileRef}
|
||||
type="file"
|
||||
accept=".css,text/css"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handleThemeFile(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={themeCss}
|
||||
onChange={(e) => setThemeCss(e.target.value)}
|
||||
placeholder=":root[data-theme="mein-theme"] { --accent-primary: #15803d; … }"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
|
||||
/>
|
||||
|
||||
{themeMsg && (
|
||||
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
|
||||
{themeMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{themeEditId && (
|
||||
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
|
||||
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1023,10 +1251,40 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Obsidian-compatible Markdown vault */}
|
||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-main">Markdown-Archiv für Obsidian (.zip)</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
|
||||
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
|
||||
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
|
||||
dabei.
|
||||
</p>
|
||||
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportVault}
|
||||
disabled={exportingVault}
|
||||
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: legal notices of this instance and project info */}
|
||||
<LegalLinks legal={legal} className="pt-4 border-t border-border">
|
||||
<button type="button" onClick={() => setShowAbout(true)} className="hover:text-main underline-offset-2 hover:underline">
|
||||
Über MindSift
|
||||
</button>
|
||||
</LegalLinks>
|
||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, isSharingDisabled, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
@@ -187,6 +187,18 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
!managedTeams.some((mt) => mt.id === t.id)
|
||||
);
|
||||
|
||||
if (isSharingDisabled()) {
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<button onClick={onBack} className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer">
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<p className="text-xs text-muted">Gruppen sind auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
@@ -203,13 +215,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen & Teams</span>
|
||||
<span>Gruppen</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{teams.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
|
||||
Erstelle Gruppen (z. B. Familie, WG oder Projektteam) aus Personen, die du sehen kannst, und teile Notizen, Aufgaben und Listen mit allen auf einmal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
@@ -34,6 +37,8 @@ interface TaskDetailModalProps {
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
|
||||
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
|
||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
assignableMembers = [],
|
||||
onUpdateAssignees,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
@@ -71,6 +78,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
||||
// The notes field grows with its text, so long notes scroll with the dialog instead of inside a small box
|
||||
const growNotes = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight + 2}px`;
|
||||
}, []);
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [newTagDraft, setNewTagDraft] = useState('');
|
||||
@@ -172,7 +185,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Header */}
|
||||
@@ -272,7 +285,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
||||
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
|
||||
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
@@ -517,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignees (shared lists only): chips + the shared type-to-search */}
|
||||
{assignableMembers.length > 0 && (() => {
|
||||
const assigned = (task.assignees || [])
|
||||
.map((id) => assignableMembers.find((m) => m.userId === id))
|
||||
.filter((m): m is TaskListMember => !!m);
|
||||
const assignedIds = assigned.map((m) => m.userId);
|
||||
const candidates: User[] = assignableMembers.map((m) => ({
|
||||
id: m.userId,
|
||||
email: m.email,
|
||||
displayName: m.displayName,
|
||||
avatarUrl: m.avatarUrl,
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
}));
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Zugewiesen:</span>
|
||||
</label>
|
||||
{assigned.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigned.map((m) => (
|
||||
<span
|
||||
key={m.userId}
|
||||
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
|
||||
<span>{m.displayName || m.email}</span>
|
||||
{!readOnly && onUpdateAssignees && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
|
||||
className="text-muted hover:text-status-err"
|
||||
title="Zuweisung entfernen"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
|
||||
)}
|
||||
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
|
||||
<UserAutocomplete
|
||||
users={candidates}
|
||||
selectedUser={null}
|
||||
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
|
||||
excludeUserIds={assignedIds}
|
||||
placeholder="Person zuweisen (Name tippen)…"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Hashtags Section */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
@@ -672,7 +746,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||
rows={6}
|
||||
autoFocus
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
||||
ref={growNotes}
|
||||
onInput={(e) => growNotes(e.currentTarget)}
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
@@ -725,6 +801,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
|
||||
@@ -26,29 +26,22 @@ import {
|
||||
Filter,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
interface TasksViewProps {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
refreshTrigger?: number;
|
||||
initialSelectedTaskId?: string | null;
|
||||
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||||
}
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
@@ -57,6 +50,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onOpenDocument,
|
||||
refreshTrigger = 0,
|
||||
initialSelectedTaskId,
|
||||
openTasksCount,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||
@@ -134,16 +128,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
||||
|
||||
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
|
||||
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
|
||||
// happened to fall into the reload (press and release then hit different elements).
|
||||
const loadedListRef = useRef<string | null>(null);
|
||||
const loadSeqRef = useRef(0);
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const seq = ++loadSeqRef.current;
|
||||
const listId = selectedListId;
|
||||
if (loadedListRef.current !== listId) setLoading(true);
|
||||
try {
|
||||
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
||||
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
|
||||
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
|
||||
api.getTaskLists().catch(() => []),
|
||||
api.getUserTeams().catch(() => []),
|
||||
api.getCollaborators().catch(() => []),
|
||||
api.getThoughts('discarded').catch(() => []),
|
||||
]);
|
||||
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||||
loadedListRef.current = listId;
|
||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||
@@ -152,7 +155,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
} catch {
|
||||
// Prevent unhandled promise rejection from crashing view
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeqRef.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,6 +239,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||||
|
||||
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
|
||||
// assignees who lost access before the task payload caught up
|
||||
const membersByList = useMemo(() => {
|
||||
const m = new Map<string, TaskListMember[]>();
|
||||
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
|
||||
return m;
|
||||
}, [safeTaskLists]);
|
||||
const assignableMembers = (task: Thought): TaskListMember[] => {
|
||||
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
|
||||
return members.length > 1 ? members : [];
|
||||
};
|
||||
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
|
||||
id: m.userId,
|
||||
name: m.displayName || m.email,
|
||||
avatarUrl: m.avatarUrl,
|
||||
});
|
||||
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
|
||||
const members = assignableMembers(task);
|
||||
return (task.assignees || [])
|
||||
.map((id) => members.find((m) => m.userId === id))
|
||||
.filter((m): m is TaskListMember => !!m)
|
||||
.map(memberToPerson);
|
||||
};
|
||||
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
|
||||
const people = getAssigneePeople(task);
|
||||
if (people.length === 0) return null;
|
||||
return (
|
||||
<AvatarStack
|
||||
people={people}
|
||||
size={size}
|
||||
ringClass="ring-[var(--bg-panel)]"
|
||||
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||
if (
|
||||
@@ -464,6 +503,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
await api.updateThought(task.id, { notes: newNotes });
|
||||
};
|
||||
|
||||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||
triggerHaptic(10);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||
try {
|
||||
const updated = await api.updateThought(task.id, { assignees });
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
|
||||
} catch {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||
await api.updateThought(task.id, { tags: newTags });
|
||||
@@ -989,6 +1039,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
id: 'list-all',
|
||||
label: 'Alle Aufgaben',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
badge: openTasksCount || undefined,
|
||||
active: selectedListId === 'all',
|
||||
onClick: () => setSelectedListId('all'),
|
||||
},
|
||||
@@ -1039,6 +1090,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
safeDiscarded.length,
|
||||
kanbanTasks.length,
|
||||
safeTaskLists,
|
||||
openTasksCount,
|
||||
currentSelectedList,
|
||||
isSelectedListReadOnly,
|
||||
setPrimaryAction,
|
||||
@@ -1274,6 +1326,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderAssignees(task, 'xs')}
|
||||
|
||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
@@ -1329,41 +1383,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
Alle Aufgaben
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-2' : ''}`}>
|
||||
Alle Aufgaben
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{safeTaskLists.map((tl) => (
|
||||
<div key={tl.id} className="relative group flex items-center">
|
||||
<div
|
||||
key={tl.id}
|
||||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedListId(tl.id)}
|
||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
||||
selectedListId === tl.id
|
||||
? 'bg-panel border-accent text-main rounded-l-btn rounded-r-none border-r-0'
|
||||
: 'bg-canvas border-border text-muted hover:text-main rounded-btn'
|
||||
}`}
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{tl.icon}</span>
|
||||
<span>{tl.title}</span>
|
||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
||||
)}
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
|
||||
{tl.title}
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||||
if (!isShared) return null;
|
||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||
if (otherMembers.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||
{otherMembers.slice(0, 2).map((m) => (
|
||||
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
getInitials(m.displayName || m.email)[0]
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<AvatarStack
|
||||
people={otherMembers.map(memberToPerson)}
|
||||
size="xxs"
|
||||
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -1377,7 +1432,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<button
|
||||
onClick={() => handleDeleteTaskList(tl.id)}
|
||||
title="Liste löschen"
|
||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
@@ -1403,7 +1458,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||
? currentSelectedList.shares.length
|
||||
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
||||
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
||||
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
|
||||
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
||||
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||||
|
||||
@@ -2103,6 +2158,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||
onUpdateNotes={handleUpdateTaskNotes}
|
||||
onUpdateTags={handleUpdateTaskTags}
|
||||
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||
onToggleSubtask={handleToggleSubtask}
|
||||
onAddSubtask={handleAddSubtask}
|
||||
onDeleteSubtask={handleDeleteSubtask}
|
||||
@@ -2305,6 +2362,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
{getAssigneePeople(task).length > 0 && (
|
||||
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Navigation controls: Move Left / Move Right */}
|
||||
|
||||
@@ -14,11 +14,16 @@ import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Check,
|
||||
CloudOff,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
@@ -39,63 +44,7 @@ export interface DocumentEditorProps {
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
interface EmojiItem {
|
||||
emoji: string;
|
||||
category: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const EMOJI_CATALOG: EmojiItem[] = [
|
||||
// Notizen & Dokumente
|
||||
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
||||
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
||||
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
||||
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
||||
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
||||
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
||||
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
||||
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
||||
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
||||
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
||||
|
||||
// Ideen & Fokus
|
||||
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
||||
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
||||
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
||||
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
||||
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
||||
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
||||
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
||||
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
||||
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
||||
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
||||
|
||||
// Tech & Dev
|
||||
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
||||
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
||||
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
||||
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
||||
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
||||
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
||||
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
||||
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
||||
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
||||
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
||||
|
||||
// Alltag & Organisation
|
||||
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
||||
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
||||
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
||||
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
||||
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
||||
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
||||
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
||||
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
||||
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
||||
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
||||
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
||||
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
||||
];
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -245,21 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||
|
||||
const filteredEmojis = useMemo(() => {
|
||||
const q = emojiSearch.trim().toLowerCase();
|
||||
if (!q) return EMOJI_CATALOG;
|
||||
return EMOJI_CATALOG.filter(
|
||||
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
||||
);
|
||||
}, [emojiSearch]);
|
||||
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
@@ -314,12 +254,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
@@ -417,6 +352,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[engineState?.presence, currentUser?.id]
|
||||
);
|
||||
|
||||
// Presence only carries names; profile pictures come from the user directory, loaded once
|
||||
// the first time someone else shows up (avatars can be data URLs, too big for every sync)
|
||||
const [avatarsById, setAvatarsById] = useState<Map<string, string>>(new Map());
|
||||
const avatarsRequestedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (otherPeople.length === 0 || avatarsRequestedRef.current) return;
|
||||
avatarsRequestedRef.current = true;
|
||||
api.getUsersDirectory()
|
||||
.then((users) => {
|
||||
const m = new Map<string, string>();
|
||||
for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl);
|
||||
setAvatarsById(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [otherPeople.length]);
|
||||
|
||||
// Short hint at the bottom of the editor (blocked actions, skipped undo)
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
@@ -755,6 +706,33 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save status on phones (the desktop shows it above the workspace) */}
|
||||
{engineState && (
|
||||
<span
|
||||
className="sm:hidden inline-flex items-center shrink-0"
|
||||
title={
|
||||
engineState.status === 'saving'
|
||||
? 'Speichert…'
|
||||
: engineState.status === 'offline'
|
||||
? 'Offline – Änderungen sind lokal gesichert'
|
||||
: engineState.status === 'error'
|
||||
? engineState.errorMessage || 'Fehler beim Speichern'
|
||||
: 'Gespeichert'
|
||||
}
|
||||
aria-label="Speicherstatus"
|
||||
>
|
||||
{engineState.status === 'saving' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
) : engineState.status === 'offline' ? (
|
||||
<CloudOff className="w-3.5 h-3.5 text-status-warn" />
|
||||
) : engineState.status === 'error' ? (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Read-only indicator */}
|
||||
{!canEdit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
||||
@@ -790,15 +768,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
{otherPeople.length > 0 && (
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title="Hat diese Notiz gerade geöffnet"
|
||||
>
|
||||
<Users className="w-3 h-3 text-accent" />
|
||||
{otherPeople.map((p) => p.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||
{/* Sharing Section */}
|
||||
{doc && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
@@ -1009,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div
|
||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={emojiSearch}
|
||||
onChange={(e) => setEmojiSearch(e.target.value)}
|
||||
placeholder="Icon suchen oder Emoji eingeben…"
|
||||
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{doc?.icon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon('');
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<span>Icon entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
{emojiSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(emojiSearch.trim());
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span>Eingabe als Icon nutzen</span>
|
||||
<span className="text-base">{emojiSearch.trim()}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
||||
{filteredEmojis.map((item) => (
|
||||
<button
|
||||
key={item.emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSelectIcon(item.emoji);
|
||||
setShowEmojiPicker(false);
|
||||
setEmojiSearch('');
|
||||
}}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
||||
title={item.keywords}
|
||||
>
|
||||
{item.emoji}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmojis.length === 0 && (
|
||||
<div className="col-span-6 text-center text-xs text-faint py-3">
|
||||
Kein passendes Standard-Icon
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmojiPickerModal
|
||||
currentIcon={doc?.icon}
|
||||
onSelectEmoji={handleSelectIcon}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
|
||||
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||
|
||||
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
|
||||
const MAX_RECENT_EMOJIS = 10;
|
||||
|
||||
function getStoredRecentEmojis(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
|
||||
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.slice(0, MAX_RECENT_EMOJIS);
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
}
|
||||
|
||||
function saveRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
const current = getStoredRecentEmojis();
|
||||
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
|
||||
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
currentIcon,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||
typeof document !== 'undefined'
|
||||
? document.documentElement.classList.contains('dark')
|
||||
: true
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sync theme changes
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Close on Escape or click outside (desktop popover)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
onSelectEmoji(emoji);
|
||||
};
|
||||
|
||||
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
|
||||
handlePick(emojiData.emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop (< 640px) */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="
|
||||
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
||||
"
|
||||
style={
|
||||
{
|
||||
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
||||
'--epr-bg-color': 'var(--bg-panel)',
|
||||
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
||||
'--epr-picker-border-color': 'var(--border-color)',
|
||||
'--epr-text-color': 'var(--text-main)',
|
||||
'--epr-search-input-bg-color': 'var(--bg-app)',
|
||||
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
|
||||
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||
'--epr-highlight-color': 'var(--accent-primary)',
|
||||
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Mobile handle indicator */}
|
||||
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
||||
|
||||
{/* Top Header: Title, Actions & Close */}
|
||||
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
||||
<span>Icon auswählen</span>
|
||||
{currentIcon && (
|
||||
<span className="text-base leading-none ml-1">{currentIcon}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{currentIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEmoji('')}
|
||||
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
|
||||
title="Icon entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Emojis Bar */}
|
||||
{recentEmojis.length > 0 && (
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="w-3 h-3 text-accent" />
|
||||
<span>Zuletzt verwendet:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handlePick(emoji)}
|
||||
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||
<span>Emoji-Katalog wird geladen…</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={handlePickerEmojiClick}
|
||||
autoFocusSearch={true}
|
||||
theme={isDark ? Theme.DARK : Theme.LIGHT}
|
||||
emojiStyle={EmojiStyle.NATIVE}
|
||||
suggestedEmojisMode={SuggestionMode.RECENT}
|
||||
searchPlaceholder="Emoji suchen…"
|
||||
searchPlaceHolder="Emoji suchen…"
|
||||
width="100%"
|
||||
height="350px"
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
skinTonesDisabled={false}
|
||||
lazyLoadEmojis={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +62,7 @@ export interface OutlinerBlockProps {
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
@@ -109,6 +110,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onFocusBlock,
|
||||
onBlurBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
@@ -288,8 +290,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
|
||||
// measure again whenever its width changes
|
||||
const el = textareaRef.current;
|
||||
let lastWidth = el?.clientWidth ?? 0;
|
||||
const observer =
|
||||
el && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => {
|
||||
if (el.clientWidth !== lastWidth) {
|
||||
lastWidth = el.clientWidth;
|
||||
adjustHeight();
|
||||
}
|
||||
})
|
||||
: null;
|
||||
if (el) observer?.observe(el);
|
||||
return () => {
|
||||
window.removeEventListener('resize', adjustHeight);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [adjustHeight, block.type]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (slashSearch === null) return [];
|
||||
@@ -392,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
setIsLocallyFocused(false);
|
||||
onBlurBlock?.(block.id);
|
||||
if (isPastingRef.current) {
|
||||
isPastingRef.current = false;
|
||||
return;
|
||||
@@ -651,16 +671,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Indentation Guide Line */}
|
||||
{visualLevel > 0 &&
|
||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||
<div
|
||||
key={lvlIdx}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
||||
style={{ left: `${lvlIdx * 22 + 12}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// Leaving a block (click into empty space) must drop its focus highlight; a focus move to
|
||||
// another block has already set the new id, so only clear if it's still this one
|
||||
const handleBlurBlock = useCallback((blockId: string) => {
|
||||
setFocusedBlockId((cur) => (cur === blockId ? null : cur));
|
||||
}, []);
|
||||
|
||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
||||
|
||||
@@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onBlurBlock={handleBlurBlock}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { AvatarStack } from '../../components/AvatarStack';
|
||||
import { Presence } from '../../lib/docSync/transport';
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
interface PresenceAvatarsProps {
|
||||
people: Presence[];
|
||||
avatars: Map<string, string>; // userId -> avatarUrl
|
||||
}
|
||||
|
||||
// Who else has the note open: the shared AvatarStack plus the full list on hover / keyboard focus
|
||||
export const PresenceAvatars: React.FC<PresenceAvatarsProps> = ({ people, avatars }) => {
|
||||
if (people.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="group/presence relative flex items-center outline-none"
|
||||
tabIndex={0}
|
||||
aria-label={`Gerade online: ${people.map((p) => p.name).join(', ')}`}
|
||||
>
|
||||
<AvatarStack
|
||||
people={people.map((p) => ({ id: p.userId, name: p.name, avatarUrl: avatars.get(p.userId) }))}
|
||||
max={MAX_VISIBLE}
|
||||
/>
|
||||
|
||||
<div className="invisible opacity-0 group-hover/presence:visible group-hover/presence:opacity-100 group-focus/presence:visible group-focus/presence:opacity-100 transition-opacity absolute right-0 top-full mt-1.5 z-30 min-w-[11rem] max-w-[16rem] p-1.5 rounded-card bg-panel border border-border shadow-lg">
|
||||
<div className="px-1.5 pb-1 text-[10px] uppercase tracking-wide text-faint">Gerade online</div>
|
||||
{people.map((p) => (
|
||||
<div key={p.userId} className="flex items-center gap-2 px-1.5 py-1 text-xs text-main">
|
||||
<span className="relative">
|
||||
<UserAvatar name={p.name} avatarUrl={avatars.get(p.userId) || ''} size="xs" />
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-status-ok ring-2 ring-[var(--bg-panel)]" />
|
||||
</span>
|
||||
<span className="truncate">{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -52,6 +52,7 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -289,12 +290,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const tagMap = new Map<string, number>();
|
||||
for (const d of docs) {
|
||||
for (const b of d.blocks || []) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const clean = m.slice(1).toLowerCase();
|
||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||
}
|
||||
for (const clean of extractHashtags(b.content)) {
|
||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1117,8 +1114,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: Workspace Tabs & Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* Workspace selector tabs & left sidebar toggle */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
||||
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
disabled={subView !== 'editor'}
|
||||
@@ -1155,7 +1152,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubView('editor')}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||
subView === 'editor'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1172,7 +1169,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('todos');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||
subView === 'todos'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1189,7 +1186,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('tags');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||
subView === 'tags' || subView === 'tag_detail'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1201,8 +1198,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
/* Save Status Pill */
|
||||
<div
|
||||
@@ -1272,7 +1269,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
||||
Notizen & Outliner
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleOpenNewFolder('', e)}
|
||||
|
||||
@@ -293,6 +293,39 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Round counter, rendered by <CountBadge> (Doku/ui-patterns.md). Accent = needs attention (nav bar),
|
||||
muted = plain amount (tabs, lists). Corner = notification badge on the top right of a relative parent. */
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.count-badge-accent {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.count-badge-muted {
|
||||
background-color: var(--text-muted);
|
||||
color: var(--bg-panel);
|
||||
}
|
||||
.count-badge-corner {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
z-index: 1;
|
||||
/* ring in the page colour keeps the badge readable on top of borders */
|
||||
box-shadow: 0 0 0 2px var(--bg-app);
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -428,3 +461,30 @@ body {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EMOJI PICKER (emoji-picker-react Integration & Mobile Touch Tuning)
|
||||
========================================================================== */
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact {
|
||||
border: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
/* Prevent iOS auto-zoom on input focus */
|
||||
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
||||
font-size: 1rem !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* Min 44px touch targets on mobile */
|
||||
.mindsift-emoji-picker .EmojiPickerReact button.epr-emoji {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractHashtags } from '../hashtags';
|
||||
|
||||
describe('extractHashtags', () => {
|
||||
it('finds tags at the start, after spaces and after "("', () => {
|
||||
expect(extractHashtags('#start mitten #zwei (#drei)')).toEqual(['start', 'zwei', 'drei']);
|
||||
});
|
||||
|
||||
it('ignores URL anchors and words like C#', () => {
|
||||
expect(extractHashtags('example.com/blog#ueberschrift')).toEqual([]);
|
||||
expect(extractHashtags('https://x.de/#anker und #echt, C# nicht')).toEqual(['echt']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
|
||||
|
||||
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
|
||||
|
||||
describe('custom themes', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
});
|
||||
|
||||
it('injects the CSS live and uses its data-theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
const style = document.getElementById('mindsift-custom-theme');
|
||||
expect(style?.textContent).toBe(lightCss);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('papier');
|
||||
// light background -> no Tailwind dark class
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores data-theme mentioned in comments (like in the template)', () => {
|
||||
setActiveTheme('custom:thm_2', `/* benenne [data-theme="mein-theme"] um */\n:root[data-theme="wald"] { --bg-app: #0b1a10; }`);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('wald');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the active custom theme on boot from the cache', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
expect(initThemeOnBoot()).toBe('custom:thm_1');
|
||||
expect(document.getElementById('mindsift-custom-theme')?.textContent).toBe(lightCss);
|
||||
});
|
||||
|
||||
it('removes the custom CSS when switching back to a built-in theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
setActiveTheme('dark');
|
||||
expect(document.getElementById('mindsift-custom-theme')).toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to dark when a custom theme has no CSS', () => {
|
||||
setActiveTheme('custom:gone');
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('builds gallery previews from the CSS variables', () => {
|
||||
const opt = customThemeOption({ id: 'thm_1', name: 'Papier', css: lightCss, createdAt: '', updatedAt: '' });
|
||||
expect(opt.id).toBe('custom:thm_1');
|
||||
expect(opt.previewBg).toBe('#fafafa');
|
||||
expect(opt.previewAccent).toBe('#15803d');
|
||||
expect(opt.isRounded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,17 +17,21 @@ import {
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
UserTheme,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
UserInvite,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
import { extractHashtags } from './hashtags';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
|
||||
@@ -123,10 +127,25 @@ 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;
|
||||
},
|
||||
|
||||
// Public, works before login
|
||||
async getLegalText(type: 'imprint' | 'privacy'): Promise<string> {
|
||||
const res = await request<{ content: string }>(`/api/v1/legal/${type}`);
|
||||
return res.content || '';
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
@@ -146,7 +165,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
instanceName?: string;
|
||||
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
||||
sharingMode?: SharingMode;
|
||||
allowRegistration?: boolean;
|
||||
createSampleData?: boolean;
|
||||
}): Promise<{ token: string; user: User }> {
|
||||
@@ -254,7 +273,7 @@ export const api = {
|
||||
id: `th_offline_${Date.now()}`,
|
||||
userId: 'offline',
|
||||
content,
|
||||
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
|
||||
tags: Array.from(new Set(extractHashtags(content))),
|
||||
priority,
|
||||
source,
|
||||
status: 'inbox',
|
||||
@@ -361,6 +380,7 @@ export const api = {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
assignees?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -828,41 +848,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
return ensureArray(res).map((o) => ({
|
||||
...o,
|
||||
teams: ensureArray(o.teams).map((t) => ({
|
||||
...t,
|
||||
members: ensureArray(t.members),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
async createOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getOrgTeams(orgId: string): Promise<Team[]> {
|
||||
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
||||
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- GROUPS (user-managed) ---
|
||||
async createTeam(name: string, description = ''): Promise<Team> {
|
||||
return request<Team>('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
@@ -922,6 +908,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
@@ -964,7 +951,7 @@ export const api = {
|
||||
async adminCreateInvite(payload: {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: string;
|
||||
organizationId?: string;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
@@ -987,6 +974,42 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// Counts shares and group memberships a switch to `mode` would remove
|
||||
async adminSharingImpact(mode: SharingMode): Promise<number> {
|
||||
const res = await request<{ affected: number }>(`/api/v1/admin/settings/sharing-impact?mode=${mode}`);
|
||||
return res.affected || 0;
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS (server admin only) ---
|
||||
async adminGetOrganizations(): Promise<Organization[]> {
|
||||
return ensureArray(await request<Organization[]>('/api/v1/admin/organizations'));
|
||||
},
|
||||
|
||||
async adminCreateOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/admin/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminRenameOrganization(id: string, name: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminSetUserOrganization(userId: string, organizationId: string): Promise<void> {
|
||||
await request(`/api/v1/admin/users/${userId}/organization`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ organizationId }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
@@ -994,6 +1017,23 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- CUSTOM THEMES ---
|
||||
async getThemes(): Promise<UserTheme[]> {
|
||||
return ensureArray(await request<UserTheme[]>('/api/v1/themes'));
|
||||
},
|
||||
|
||||
// Creates a theme, or updates it when `id` is given
|
||||
async saveTheme(theme: { id?: string; name: string; css: string }): Promise<UserTheme> {
|
||||
return request<UserTheme>('/api/v1/themes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(theme),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTheme(id: string): Promise<void> {
|
||||
await request(`/api/v1/themes/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
const res = await request<APIKey[]>('/api/v1/keys');
|
||||
@@ -1026,8 +1066,8 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
async searchAll(q: string): Promise<SearchResults> {
|
||||
return request<SearchResults>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
@@ -1035,6 +1075,17 @@ export const api = {
|
||||
async exportAll(): Promise<any> {
|
||||
return request<any>('/api/v1/export');
|
||||
},
|
||||
|
||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||
async exportMarkdownVault(): Promise<Blob> {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/v1/export/markdown', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
||||
export function getOfflineQueue(): Thought[] {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||
// and words like "C#" are not tags. Same rule as the server (domain.ExtractTags).
|
||||
const HASHTAG = /(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
||||
|
||||
/** All hashtags in a text, lower case, in order of appearance (with duplicates). */
|
||||
export function extractHashtags(text: string | undefined | null): string[] {
|
||||
if (!text) return [];
|
||||
return Array.from(text.matchAll(HASHTAG), (m) => m[1].toLowerCase());
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export type ThemeId = 'dark' | 'light' | 'high-contrast' | 'soft';
|
||||
import type { UserTheme } from './types';
|
||||
|
||||
// Built-in themes are compiled CSS files (src/themes/); custom themes are user CSS from the server,
|
||||
// injected at runtime and addressed as "custom:<id>"
|
||||
export type ThemeId = string;
|
||||
|
||||
export interface ThemeOption {
|
||||
id: ThemeId;
|
||||
@@ -10,9 +14,14 @@ export interface ThemeOption {
|
||||
previewAccent: string;
|
||||
previewText: string;
|
||||
isRounded: boolean;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
|
||||
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
|
||||
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
|
||||
const CUSTOM_STYLE_ID = 'mindsift-custom-theme';
|
||||
export const CUSTOM_THEME_PREFIX = 'custom:';
|
||||
|
||||
export const AVAILABLE_THEMES: ThemeOption[] = [
|
||||
{
|
||||
@@ -61,43 +70,105 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getActiveTheme(): ThemeId {
|
||||
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
|
||||
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
|
||||
|
||||
// Comments are ignored when reading the CSS (the template mentions [data-theme="mein-theme"] in one)
|
||||
const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
const readCssVar = (css: string, name: string): string | undefined =>
|
||||
stripComments(css).match(new RegExp(`--${name}\\s*:\\s*([^;]+);`))?.[1].trim();
|
||||
|
||||
// The CSS decides its own [data-theme="…"]; without one it targets plain :root
|
||||
const themeAttribute = (css: string): string =>
|
||||
stripComments(css).match(/data-theme\s*=\s*["']?([\w-]+)/)?.[1] || 'custom';
|
||||
|
||||
// Light background -> no Tailwind dark class
|
||||
function isLightColor(color?: string): boolean {
|
||||
const hex = color?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
||||
if (!hex) return false;
|
||||
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
|
||||
}
|
||||
|
||||
export function customThemeOption(t: UserTheme): ThemeOption {
|
||||
const radius = readCssVar(t.css, 'radius-card');
|
||||
return {
|
||||
id: customThemeId(t),
|
||||
name: t.name,
|
||||
subtitle: 'Eigenes Theme',
|
||||
file: 'Eigenes Theme (gespeichert auf dem Server)',
|
||||
previewBg: readCssVar(t.css, 'bg-app') || '#121316',
|
||||
previewPanel: readCssVar(t.css, 'bg-panel') || '#1a1c20',
|
||||
previewAccent: readCssVar(t.css, 'accent-primary') || '#2563eb',
|
||||
previewText: readCssVar(t.css, 'text-main') || '#f4f4f5',
|
||||
isRounded: !!radius && !/^0(px)?$/.test(radius),
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
|
||||
function injectCustomCss(css: string | null): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
let el = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (css === null) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.id = CUSTOM_STYLE_ID;
|
||||
}
|
||||
el.textContent = css;
|
||||
// Last in <head>, so it wins over the compiled themes
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
injectCustomCss(customCss);
|
||||
const root = document.documentElement;
|
||||
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
|
||||
root.setAttribute('data-theme', attr);
|
||||
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
||||
root.classList.toggle('dark', !light);
|
||||
}
|
||||
|
||||
function store(key: string, value: string | null): void {
|
||||
try {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
|
||||
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
|
||||
return saved;
|
||||
}
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function load(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveTheme(): ThemeId {
|
||||
const saved = load(THEME_STORAGE_KEY);
|
||||
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
|
||||
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export function setActiveTheme(themeId: ThemeId): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themeId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', themeId);
|
||||
if (themeId !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
// customCss is required for custom themes and ignored for built-in ones
|
||||
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
|
||||
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
|
||||
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
|
||||
store(THEME_STORAGE_KEY, effectiveId);
|
||||
store(CUSTOM_CSS_CACHE_KEY, css);
|
||||
applyToDocument(effectiveId, css);
|
||||
}
|
||||
|
||||
export function initThemeOnBoot(): ThemeId {
|
||||
const active = getActiveTheme();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', active);
|
||||
if (active !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
|
||||
return active;
|
||||
}
|
||||
@@ -7,8 +7,19 @@ export interface AuthStatus {
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
sharingMode?: SharingMode;
|
||||
// Impressum / Datenschutz of this instance (LEGAL_* environment variables)
|
||||
legal?: {
|
||||
imprintUrl?: string;
|
||||
hasImprintContent?: boolean;
|
||||
privacyUrl?: string;
|
||||
hasPrivacyContent?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 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 +33,7 @@ export interface User {
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
organizationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -30,7 +42,8 @@ export interface UserInvite {
|
||||
code: string;
|
||||
email?: string;
|
||||
role: UserRole;
|
||||
teamId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
createdBy: string;
|
||||
usedBy?: string;
|
||||
createdAt: string;
|
||||
@@ -62,6 +75,7 @@ export interface Thought {
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
assignees?: string[]; // user IDs, only people with access to the task list
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
@@ -100,8 +114,6 @@ export interface TaskList {
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
@@ -117,11 +129,11 @@ export interface TaskList {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Defined by server admins; every user belongs to at most one
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
teams?: Team[];
|
||||
memberCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -145,7 +157,7 @@ export interface TeamMember {
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
sharingMode: 'open' | 'teams_only' | 'strict_private';
|
||||
sharingMode: SharingMode;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
smtpHost?: string;
|
||||
@@ -154,6 +166,7 @@ export interface SystemSettings {
|
||||
smtpPass?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
smtpSkipVerify?: boolean;
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
}
|
||||
|
||||
@@ -291,6 +304,25 @@ export interface TagDetails {
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
type: 'document' | 'thought' | 'list_item';
|
||||
id: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
listId?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
thoughts: Thought[];
|
||||
documents: Document[];
|
||||
listItems: ListItem[];
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
@@ -349,6 +381,8 @@ export interface ListItem {
|
||||
isCompleted: boolean;
|
||||
completedBy?: string;
|
||||
completedByName?: string;
|
||||
createdBy?: string;
|
||||
createdByName?: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
@@ -378,10 +412,20 @@ export interface SharedList {
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
members?: TaskListMember[]; // owner + everyone the list is shared with
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Custom CSS theme of the current user (Benutzereinstellungen > Darstellung)
|
||||
export interface UserTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
css: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
|
||||
--accent-primary: #2563eb;
|
||||
--accent-hover: #3b82f6;
|
||||
--accent-dim: rgba(37, 99, 235, 0.14);
|
||||
--accent-border: rgba(59, 130, 246, 0.45);
|
||||
/* Tints follow --accent-primary automatically; set fixed values only if you want different ones */
|
||||
--accent-dim: color-mix(in srgb, var(--accent-primary) 14%, transparent);
|
||||
--accent-border: color-mix(in srgb, var(--accent-hover) 45%, transparent);
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,3 +1,11 @@
|
||||
// Theme colours are CSS variables, so Tailwind cannot apply opacity modifiers (bg-accent/15) by itself.
|
||||
// v() mixes the variable with transparent for a modifier; plain classes keep the bare variable, so only
|
||||
// the tints depend on color-mix() support.
|
||||
const v = (name) => ({ opacityValue }) =>
|
||||
opacityValue === undefined || opacityValue === '1' || String(opacityValue).startsWith('var(')
|
||||
? `var(${name})`
|
||||
: `color-mix(in srgb, var(${name}) calc(${opacityValue} * 100%), transparent)`;
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
@@ -5,48 +13,57 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg-app)',
|
||||
canvas: v('--bg-app'),
|
||||
panel: {
|
||||
DEFAULT: 'var(--bg-panel)',
|
||||
elevated: 'var(--bg-panel-elevated)',
|
||||
hover: 'var(--bg-panel-hover)',
|
||||
DEFAULT: v('--bg-panel'),
|
||||
elevated: v('--bg-panel-elevated'),
|
||||
hover: v('--bg-panel-hover'),
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: 'var(--bg-panel)',
|
||||
elevated: 'var(--bg-panel-elevated)',
|
||||
hover: 'var(--bg-panel-hover)',
|
||||
border: 'var(--border-color)',
|
||||
DEFAULT: v('--bg-panel'),
|
||||
elevated: v('--bg-panel-elevated'),
|
||||
hover: v('--bg-panel-hover'),
|
||||
border: v('--border-color'),
|
||||
},
|
||||
input: 'var(--bg-input)',
|
||||
overlay: 'var(--bg-overlay)',
|
||||
code: 'var(--bg-code)',
|
||||
main: 'var(--text-main)',
|
||||
muted: 'var(--text-muted)',
|
||||
faint: 'var(--text-faint)',
|
||||
'on-accent': 'var(--text-on-accent)',
|
||||
input: v('--bg-input'),
|
||||
overlay: v('--bg-overlay'),
|
||||
code: v('--bg-code'),
|
||||
main: v('--text-main'),
|
||||
muted: v('--text-muted'),
|
||||
faint: v('--text-faint'),
|
||||
'on-accent': v('--text-on-accent'),
|
||||
accent: {
|
||||
DEFAULT: 'var(--accent-primary)',
|
||||
hover: 'var(--accent-hover)',
|
||||
dim: 'var(--accent-dim)',
|
||||
border: 'var(--accent-border)',
|
||||
DEFAULT: v('--accent-primary'),
|
||||
hover: v('--accent-hover'),
|
||||
dim: v('--accent-dim'),
|
||||
border: v('--accent-border'),
|
||||
},
|
||||
brand: {
|
||||
DEFAULT: 'var(--accent-primary)',
|
||||
hover: 'var(--accent-hover)',
|
||||
dark: 'var(--accent-primary)',
|
||||
DEFAULT: v('--accent-primary'),
|
||||
hover: v('--accent-hover'),
|
||||
dark: v('--accent-primary'),
|
||||
},
|
||||
status: {
|
||||
ok: 'var(--color-success)',
|
||||
'ok-bg': 'var(--bg-success)',
|
||||
'ok-border': 'var(--border-success)',
|
||||
err: 'var(--color-danger)',
|
||||
'err-bg': 'var(--bg-danger)',
|
||||
'err-border': 'var(--border-danger)',
|
||||
warn: 'var(--color-warning)',
|
||||
'warn-bg': 'var(--bg-warning)',
|
||||
'warn-border': 'var(--border-warning)',
|
||||
ok: v('--color-success'),
|
||||
'ok-bg': v('--bg-success'),
|
||||
'ok-border': v('--border-success'),
|
||||
err: v('--color-danger'),
|
||||
'err-bg': v('--bg-danger'),
|
||||
'err-border': v('--border-danger'),
|
||||
warn: v('--color-warning'),
|
||||
'warn-bg': v('--bg-warning'),
|
||||
'warn-border': v('--border-warning'),
|
||||
info: v('--accent-hover'),
|
||||
danger: v('--color-danger'),
|
||||
},
|
||||
border: 'var(--border-color)',
|
||||
border: v('--border-color'),
|
||||
// Aliases for names used in components (e.g. avatar initials), mapped to theme colours
|
||||
primary: v('--accent-primary'),
|
||||
secondary: v('--text-muted'),
|
||||
info: v('--accent-hover'),
|
||||
success: v('--color-success'),
|
||||
warning: v('--color-warning'),
|
||||
danger: v('--color-danger'),
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: 'var(--border-color)',
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// smtpSettings is the effective mail server configuration (admin settings, else environment).
|
||||
type smtpSettings struct {
|
||||
Host, Port, User, Pass, From string
|
||||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||||
SkipVerify bool // accept self-signed / mismatching certificates
|
||||
}
|
||||
|
||||
// smtpStepError names the protocol step at which the mail server refused, e.g. "RCPT TO".
|
||||
type smtpStepError struct {
|
||||
Step string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *smtpStepError) Error() string {
|
||||
return fmt.Sprintf("SMTP-Schritt %s fehlgeschlagen: %v", e.Step, e.Err)
|
||||
}
|
||||
|
||||
func (e *smtpStepError) Unwrap() error { return e.Err }
|
||||
|
||||
const smtpTimeout = 30 * time.Second
|
||||
|
||||
func (s *Server) smtpSettings() (smtpSettings, error) {
|
||||
cfg := smtpSettings{Port: "587", TLS: true}
|
||||
if sys, _ := s.store.GetSystemSettings(); sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||||
cfg = smtpSettings{
|
||||
Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort),
|
||||
User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom),
|
||||
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
|
||||
}
|
||||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||||
cfg = smtpSettings{
|
||||
Host: strings.TrimSpace(s.cfg.SMTPHost), Port: strings.TrimSpace(s.cfg.SMTPPort),
|
||||
User: strings.TrimSpace(s.cfg.SMTPUser), Pass: s.cfg.SMTPPass, From: strings.TrimSpace(s.cfg.SMTPFrom),
|
||||
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
|
||||
}
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
return cfg, fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||||
}
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "587"
|
||||
}
|
||||
if cfg.From == "" {
|
||||
if strings.Contains(cfg.User, "@") {
|
||||
cfg.From = cfg.User
|
||||
} else {
|
||||
cfg.From = "noreply@mindsift.local"
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ehloName is how MindSift introduces itself (EHLO). It must name the sending side, never "localhost",
|
||||
// which strict servers (Exchange/O365, Postfix with HELO checks) refuse: the sender's mail domain,
|
||||
// else the machine's hostname.
|
||||
func ehloName(from string) string {
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 {
|
||||
if domain := strings.Trim(from[at+1:], " >"); strings.Contains(domain, ".") {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
if h, err := os.Hostname(); err == nil && strings.Contains(h, ".") {
|
||||
return h
|
||||
}
|
||||
return "mindsift.local"
|
||||
}
|
||||
|
||||
// splitAddress turns "MindSift <alarm@firma.de>" into the bare address for the SMTP envelope
|
||||
// (MAIL FROM / RCPT TO only accept that) and the header form with an encoded display name.
|
||||
func splitAddress(raw string) (envelope, header string, err error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw)
|
||||
}
|
||||
return addr.Address, addr.String(), nil
|
||||
}
|
||||
|
||||
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
|
||||
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
|
||||
func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
||||
idBytes := make([]byte, 12)
|
||||
_, _ = rand.Read(idBytes)
|
||||
domain := ehloName(from)
|
||||
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&buf, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
qp := quotedprintable.NewWriter(&buf)
|
||||
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")))
|
||||
_ = qp.Close()
|
||||
buf.WriteString("\r\n")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// sendMail runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
fromAddr, fromHeader, err := splitAddress(cfg.From)
|
||||
if err != nil {
|
||||
return &smtpStepError{"Absender", err}
|
||||
}
|
||||
toAddr, toHeader, err := splitAddress(to)
|
||||
if err != nil {
|
||||
return &smtpStepError{"Empfänger", err}
|
||||
}
|
||||
addr := net.JoinHostPort(cfg.Host, cfg.Port)
|
||||
tlsCfg := &tls.Config{ServerName: cfg.Host, InsecureSkipVerify: cfg.SkipVerify} //nolint:gosec // admin opt-in for self-signed certificates
|
||||
dialer := &net.Dialer{Timeout: smtpTimeout}
|
||||
implicitTLS := cfg.Port == "465" && cfg.TLS
|
||||
|
||||
var conn net.Conn
|
||||
if implicitTLS {
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
||||
} else {
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return &smtpStepError{"Verbindung", err}
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * smtpTimeout))
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return &smtpStepError{"Begrüßung", err}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Hello(ehloName(cfg.From)); err != nil {
|
||||
return &smtpStepError{"EHLO", err}
|
||||
}
|
||||
if cfg.TLS && !implicitTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return &smtpStepError{"STARTTLS", fmt.Errorf("der Server bietet kein STARTTLS an (TLS deaktivieren oder Port 465 nutzen)")}
|
||||
}
|
||||
if err := client.StartTLS(tlsCfg); err != nil {
|
||||
return &smtpStepError{"STARTTLS", err}
|
||||
}
|
||||
}
|
||||
if cfg.User != "" || cfg.Pass != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)); err != nil {
|
||||
return &smtpStepError{"AUTH", err}
|
||||
}
|
||||
}
|
||||
if err := client.Mail(fromAddr); err != nil {
|
||||
return &smtpStepError{"MAIL FROM", err}
|
||||
}
|
||||
if err := client.Rcpt(toAddr); err != nil {
|
||||
return &smtpStepError{"RCPT TO", err}
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return &smtpStepError{"DATA", err}
|
||||
}
|
||||
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil {
|
||||
_ = wc.Close()
|
||||
return &smtpStepError{"DATA", err}
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
// The server answers after the final "." – content filters reject here
|
||||
return &smtpStepError{"Nachricht annehmen", err}
|
||||
}
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSMTP is a minimal ESMTP server with STARTTLS (self-signed) and AUTH PLAIN.
|
||||
type fakeSMTP struct {
|
||||
addr string
|
||||
rcptCode string // e.g. "451 4.3.2 Internal server error" to refuse RCPT TO
|
||||
|
||||
mu sync.Mutex
|
||||
ehlo string
|
||||
mailFrom string
|
||||
rcptTo string
|
||||
data string
|
||||
}
|
||||
|
||||
func selfSignedCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "mail.intern"},
|
||||
DNSNames: []string{"mail.intern"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("cert: %v", err)
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
func startFakeSMTP(t *testing.T, rcptCode string) *fakeSMTP {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
f := &fakeSMTP{addr: ln.Addr().String(), rcptCode: rcptCode}
|
||||
tlsCfg := &tls.Config{Certificates: []tls.Certificate{selfSignedCert(t)}}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go f.serve(conn, tlsCfg)
|
||||
}
|
||||
}()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) serve(conn net.Conn, tlsCfg *tls.Config) {
|
||||
defer conn.Close()
|
||||
r, w := bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
reply := func(s string) { _, _ = w.WriteString(s + "\r\n"); _ = w.Flush() }
|
||||
reply("220 fake ESMTP")
|
||||
secure := false
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO "):
|
||||
f.mu.Lock()
|
||||
f.ehlo = cmd[5:]
|
||||
f.mu.Unlock()
|
||||
if secure {
|
||||
reply("250-fake\r\n250 AUTH PLAIN")
|
||||
} else {
|
||||
reply("250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN")
|
||||
}
|
||||
case upper == "STARTTLS":
|
||||
reply("220 go ahead")
|
||||
tlsConn := tls.Server(conn, tlsCfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
conn, secure = tlsConn, true
|
||||
r, w = bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
case strings.HasPrefix(upper, "AUTH "):
|
||||
reply("235 ok")
|
||||
case strings.HasPrefix(upper, "MAIL FROM"):
|
||||
f.mu.Lock()
|
||||
f.mailFrom = strings.TrimSpace(cmd[len("MAIL FROM:"):])
|
||||
f.mu.Unlock()
|
||||
reply("250 ok")
|
||||
case strings.HasPrefix(upper, "RCPT TO"):
|
||||
f.mu.Lock()
|
||||
f.rcptTo = strings.TrimSpace(cmd[len("RCPT TO:"):])
|
||||
f.mu.Unlock()
|
||||
if f.rcptCode != "" {
|
||||
reply(f.rcptCode)
|
||||
} else {
|
||||
reply("250 ok")
|
||||
}
|
||||
case upper == "DATA":
|
||||
reply("354 go")
|
||||
var sb strings.Builder
|
||||
for {
|
||||
l, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if l == ".\r\n" {
|
||||
break
|
||||
}
|
||||
sb.WriteString(l)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.data = sb.String()
|
||||
f.mu.Unlock()
|
||||
reply("250 queued")
|
||||
case upper == "QUIT":
|
||||
reply("221 bye")
|
||||
return
|
||||
default:
|
||||
reply("250 ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) settings(skipVerify bool) smtpSettings {
|
||||
host, port, _ := net.SplitHostPort(f.addr)
|
||||
return smtpSettings{Host: host, Port: port, User: "alarm@firma.de", Pass: "geheim", From: "alarm@firma.de", TLS: true, SkipVerify: skipVerify}
|
||||
}
|
||||
|
||||
func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
|
||||
// The certificate is self-signed: refused unless verification is switched off
|
||||
err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo")
|
||||
var stepErr *smtpStepError
|
||||
if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" {
|
||||
t.Fatalf("expected a STARTTLS error with verification on, got %v", err)
|
||||
}
|
||||
|
||||
if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil {
|
||||
t.Fatalf("send with SkipVerify failed: %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
ehlo, data := f.ehlo, f.data
|
||||
f.mu.Unlock()
|
||||
|
||||
if ehlo != "firma.de" {
|
||||
t.Errorf("EHLO should name the sender domain, got %q", ehlo)
|
||||
}
|
||||
for _, header := range []string{"Date: ", "Message-ID: <", "Content-Transfer-Encoding: quoted-printable"} {
|
||||
if !strings.Contains(data, header) {
|
||||
t.Errorf("message lacks %q:\n%s", header, data)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(data, "Subject: =?utf-8?q?") {
|
||||
t.Errorf("subject with emoji/umlauts must be RFC 2047 encoded:\n%s", data)
|
||||
}
|
||||
if strings.Contains(data, "fällig") {
|
||||
t.Errorf("raw non-ASCII must not appear in the message:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMailReportsTheRefusingStep(t *testing.T) {
|
||||
f := startFakeSMTP(t, "451 4.3.2 Internal server error")
|
||||
err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo")
|
||||
var stepErr *smtpStepError
|
||||
if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") {
|
||||
t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The admin UI suggests "MindSift <alerts@domain>" as sender; the envelope must carry the bare address
|
||||
func TestSendMailWithDisplayNameSender(t *testing.T) {
|
||||
f := startFakeSMTP(t, "")
|
||||
cfg := f.settings(true)
|
||||
cfg.From = "MindSift Alarme <alarm@firma.de>"
|
||||
if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.mailFrom != "<alarm@firma.de>" || f.rcptTo != "<anna@example.com>" {
|
||||
t.Errorf("envelope must hold bare addresses, got MAIL FROM %q, RCPT TO %q", f.mailFrom, f.rcptTo)
|
||||
}
|
||||
if !strings.Contains(f.data, `From: "MindSift Alarme" <alarm@firma.de>`) {
|
||||
t.Errorf("header From should keep the display name:\n%s", f.data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEHLONameNeverLocalhost(t *testing.T) {
|
||||
if got := ehloName("Alarm <alarm@mail.example.org>"); got != "mail.example.org" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := ehloName("noreply"); got == "localhost" || got == "" {
|
||||
t.Errorf("fallback must not be localhost, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -85,6 +83,7 @@ func (s *Server) Router() http.Handler {
|
||||
// Public Health, Setup & Auth Routes
|
||||
r.Get("/api/v1/health", s.handleHealth)
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Get("/api/v1/legal/{type}", s.handleLegalText)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
@@ -110,6 +109,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||
r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme)
|
||||
|
||||
// Instant Capture & Thoughts
|
||||
r.Post("/api/v1/inbox", s.handleCaptureInbox)
|
||||
@@ -191,12 +194,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 +208,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 +227,14 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(s.adminMiddleware)
|
||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
|
||||
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||
|
||||
// Organizations decide who can see whom; only server admins manage them
|
||||
r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations)
|
||||
r.Post("/api/v1/admin/organizations", s.handleAdminCreateOrganization)
|
||||
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
||||
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
||||
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
|
||||
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
|
||||
})
|
||||
})
|
||||
@@ -254,6 +260,29 @@ func (s *Server) Router() http.Handler {
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
if path == "index.html" {
|
||||
if data, err := fs.ReadFile(s.staticFS, "index.html"); err == nil {
|
||||
scheme := "http"
|
||||
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
if host != "" {
|
||||
origin := scheme + "://" + host
|
||||
htmlStr := string(data)
|
||||
htmlStr = strings.ReplaceAll(htmlStr, `content="/og-image.png"`, `content="`+origin+`/og-image.png"`)
|
||||
htmlStr = strings.ReplaceAll(htmlStr, `property="og:url" content="/"`, `property="og:url" content="`+origin+`/"`)
|
||||
data = []byte(htmlStr)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
fileServer.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
@@ -276,7 +305,7 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
||||
|
||||
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
|
||||
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) || errors.Is(err, storage.ErrAssigneeNoAccess) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -430,9 +459,33 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
"legal": map[string]interface{}{
|
||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||
"hasImprintContent": s.cfg.Legal.ImprintContent != "",
|
||||
"privacyUrl": s.cfg.Legal.PrivacyURL,
|
||||
"hasPrivacyContent": s.cfg.Legal.PrivacyContent != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login).
|
||||
func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) {
|
||||
content := ""
|
||||
switch chi.URLParam(r, "type") {
|
||||
case "imprint":
|
||||
content = s.cfg.Legal.ImprintContent
|
||||
case "privacy":
|
||||
content = s.cfg.Legal.PrivacyContent
|
||||
}
|
||||
if content == "" {
|
||||
writeError(w, http.StatusNotFound, "Kein Text hinterlegt")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"content": content})
|
||||
}
|
||||
|
||||
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := s.store.CountUsers()
|
||||
if err != nil || count > 0 {
|
||||
@@ -447,7 +500,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
InstanceName string `json:"instanceName"`
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
SharingMode string `json:"sharingMode"`
|
||||
SeedSampleData bool `json:"seedSampleData"`
|
||||
CreateSampleData bool `json:"createSampleData"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
@@ -479,13 +532,18 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||
}
|
||||
sys.AllowRegistration = req.AllowRegistration
|
||||
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
|
||||
sys.SharingMode = req.SharingMode
|
||||
}
|
||||
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
||||
_ = s.store.UpdateSystemSettings(sys)
|
||||
// In organization mode the first admin starts in an organization named after the instance
|
||||
if sys.SharingMode == storage.SharingOrganizations {
|
||||
if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil {
|
||||
_ = s.store.SetUserOrganization(user.ID, org.ID)
|
||||
user.OrganizationID = org.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.SeedSampleData {
|
||||
if req.CreateSampleData {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
@@ -560,8 +618,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if validInvite != nil {
|
||||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||||
if validInvite.TeamID != "" {
|
||||
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
|
||||
if validInvite.OrganizationID != "" {
|
||||
_ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID)
|
||||
user.OrganizationID = validInvite.OrganizationID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,6 +950,7 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -903,6 +963,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Assignees != nil {
|
||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
updated.Assignees = assignees
|
||||
}
|
||||
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
|
||||
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
||||
s.notifyDocChanged(docID, head)
|
||||
@@ -1629,6 +1697,7 @@ func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
@@ -1640,7 +1709,7 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0)
|
||||
item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1995,15 +2064,66 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
q := r.URL.Query().Get("q")
|
||||
thoughts, docs, err := s.store.SearchAll(userID, q)
|
||||
res, err := s.store.SearchGlobal(userID, q)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"thoughts": thoughts,
|
||||
"documents": docs,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// --- CUSTOM THEMES ---
|
||||
|
||||
func (s *Server) handleGetThemes(w http.ResponseWriter, r *http.Request) {
|
||||
themes, err := s.store.GetUserThemes(auth.GetUserID(r.Context()))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, themes)
|
||||
}
|
||||
|
||||
// handleSaveTheme creates a theme, or updates it when an id of the caller's theme is given.
|
||||
func (s *Server) handleSaveTheme(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CSS string `json:"css"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxThemeCSSBytes+16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage oder Theme zu groß")
|
||||
return
|
||||
}
|
||||
theme, err := s.store.SaveUserTheme(auth.GetUserID(r.Context()), strings.TrimSpace(req.ID), req.Name, req.CSS)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if req.ID != "" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
writeJSON(w, status, theme)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteUserTheme(auth.GetUserID(r.Context()), chi.URLParam(r, "id")); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
|
||||
// Headers are already sent; the client gets a truncated archive
|
||||
slog.Error("Markdown-Export fehlgeschlagen", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2211,7 +2331,6 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
Columns *[]domain.KanbanColumn `json:"columns"`
|
||||
}
|
||||
@@ -2220,7 +2339,7 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
audienceBefore := s.store.GetTaskListUserIDs(listID)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.SpaceID, req.Columns)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
@@ -2321,9 +2440,8 @@ func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string,
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
|
||||
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgs, err := s.store.GetOrganizationsForUser(userID)
|
||||
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
orgs, err := s.store.GetOrganizations()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -2331,8 +2449,7 @@ func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, orgs)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -2340,7 +2457,7 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
org, err := s.store.CreateOrganization(userID, req.Name)
|
||||
org, err := s.store.CreateOrganization(req.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -2348,45 +2465,52 @@ func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
if err := s.store.DeleteOrganization(orgID); err != nil {
|
||||
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := chi.URLParam(r, "id")
|
||||
teams, err := s.store.GetTeamsForOrg(orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, teams)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
orgID := chi.URLParam(r, "id")
|
||||
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
|
||||
if err != nil {
|
||||
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Automatically add creator to team
|
||||
_ = s.store.AddTeamMember(team.ID, userID, "admin")
|
||||
team.Members, _ = s.store.GetTeamMembers(team.ID)
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
||||
}
|
||||
|
||||
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
|
||||
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
|
||||
}
|
||||
|
||||
func (s *Server) isTeamAdmin(userID, teamID string) bool {
|
||||
@@ -2405,20 +2529,25 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
OrgID string `json:"orgId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
|
||||
if s.store.SharingMode() == storage.SharingPrivate {
|
||||
writeError(w, http.StatusForbidden, storage.ErrSharingDisabled.Error())
|
||||
return
|
||||
}
|
||||
orgID := strings.TrimSpace(req.OrgID)
|
||||
if orgID == "" {
|
||||
orgID = "default"
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name für die Gruppe ist erforderlich")
|
||||
return
|
||||
}
|
||||
// A group belongs to its creator's organization; members can only come from there
|
||||
creator, _ := s.store.GetUserByID(userID)
|
||||
orgID := ""
|
||||
if creator != nil {
|
||||
orgID = creator.OrganizationID
|
||||
}
|
||||
team, err := s.store.CreateTeam(orgID, name, req.Description)
|
||||
if err != nil {
|
||||
@@ -2501,17 +2630,19 @@ func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
targetUserID := strings.TrimSpace(req.UserID)
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
|
||||
u, err := s.store.GetUserByEmail(req.Email)
|
||||
if err != nil || u == nil {
|
||||
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
|
||||
return
|
||||
if u, err := s.store.GetUserByEmail(req.Email); err == nil && u != nil {
|
||||
targetUserID = u.ID
|
||||
}
|
||||
targetUserID = u.ID
|
||||
}
|
||||
if targetUserID == "" {
|
||||
if targetUserID == "" && strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
// Only people the group admin can see; unknown and invisible look the same
|
||||
if targetUserID == "" || !s.store.CanShareWith(userID, "user", targetUserID) {
|
||||
writeError(w, http.StatusNotFound, storage.ErrTargetNotFound.Error())
|
||||
return
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if role != "admin" && role != "member" {
|
||||
role = "member"
|
||||
@@ -2588,10 +2719,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -2619,6 +2751,11 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.OrganizationID != "" {
|
||||
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
|
||||
u.OrganizationID = req.OrganizationID
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
@@ -2727,10 +2864,10 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
caller, _ := s.store.GetUserByID(callerID)
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
TeamID string `json:"teamId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
ValidDays int `json:"validDays"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -2739,7 +2876,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request)
|
||||
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||
req.Role = "member"
|
||||
}
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
|
||||
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -2774,10 +2911,15 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
|
||||
if settings.SharingMode != previousMode {
|
||||
_, _ = s.store.PruneSharing(settings.SharingMode, false)
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
@@ -2812,85 +2954,19 @@ func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
sys, _ := s.store.GetSystemSettings()
|
||||
host := ""
|
||||
port := "587"
|
||||
user := ""
|
||||
pass := ""
|
||||
from := ""
|
||||
useTLS := true
|
||||
|
||||
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(sys.SMTPHost)
|
||||
port = strings.TrimSpace(sys.SMTPPort)
|
||||
user = strings.TrimSpace(sys.SMTPUser)
|
||||
pass = sys.SMTPPass
|
||||
from = strings.TrimSpace(sys.SMTPFrom)
|
||||
useTLS = sys.SMTPTLS
|
||||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||||
host = strings.TrimSpace(s.cfg.SMTPHost)
|
||||
port = strings.TrimSpace(s.cfg.SMTPPort)
|
||||
user = strings.TrimSpace(s.cfg.SMTPUser)
|
||||
pass = s.cfg.SMTPPass
|
||||
from = strings.TrimSpace(s.cfg.SMTPFrom)
|
||||
useTLS = s.cfg.SMTPTLS
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
|
||||
}
|
||||
if port == "" {
|
||||
port = "587"
|
||||
}
|
||||
if from == "" {
|
||||
if user != "" && strings.Contains(user, "@") {
|
||||
from = user
|
||||
} else {
|
||||
from = "noreply@mindsift.local"
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", host, port)
|
||||
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
|
||||
|
||||
var smtpAuth smtp.Auth
|
||||
if user != "" || pass != "" {
|
||||
smtpAuth = smtp.PlainAuth("", user, pass, host)
|
||||
}
|
||||
|
||||
if port == "465" && useTLS {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Quit()
|
||||
if smtpAuth != nil {
|
||||
if err := client.Auth(smtpAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(toEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = wc.Write([]byte(msg))
|
||||
_ = wc.Close()
|
||||
cfg, err := s.smtpSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
|
||||
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
|
||||
step := ""
|
||||
if se, ok := err.(*smtpStepError); ok {
|
||||
step = se.Step
|
||||
}
|
||||
slog.Warn("E-Mail-Versand fehlgeschlagen", "smtp_step", step, "host", cfg.Host, "port", cfg.Port, "to", toEmail, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
@@ -3004,4 +3080,3 @@ func (s *Server) startAlarmTicker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,91 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
|
||||
func TestLegalTextsArePublic(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "legal.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key",
|
||||
Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}}
|
||||
router := NewServer(cfg, store, ws.NewHub(), nil).Router()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil))
|
||||
var st struct {
|
||||
Legal struct {
|
||||
ImprintURL string `json:"imprintUrl"`
|
||||
HasImprintContent bool `json:"hasImprintContent"`
|
||||
PrivacyURL string `json:"privacyUrl"`
|
||||
HasPrivacyContent bool `json:"hasPrivacyContent"`
|
||||
} `json:"legal"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &st)
|
||||
if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" {
|
||||
t.Fatalf("unexpected legal status: %+v", st.Legal)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/imprint", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Max Muster") {
|
||||
t.Fatalf("imprint without login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/legal/privacy", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("privacy without text should be 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,45 @@ type Config struct {
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
SMTPTLS bool
|
||||
// SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers)
|
||||
SMTPSkipVerify bool
|
||||
|
||||
// Legal notices (Impressum, Datenschutz): an external URL and/or Markdown text, shown even before login
|
||||
Legal LegalTexts
|
||||
}
|
||||
|
||||
type LegalTexts struct {
|
||||
ImprintURL, ImprintContent string
|
||||
PrivacyURL, PrivacyContent string
|
||||
}
|
||||
|
||||
// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE.
|
||||
func legalContent(name string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
}
|
||||
if path := strings.TrimSpace(os.Getenv(name + "_FILE")); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
slog.Warn("Rechtstext konnte nicht gelesen werden", "variable", name+"_FILE", "error", err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// legalURL accepts only http(s) links, so a misconfiguration cannot inject javascript: URLs.
|
||||
func legalURL(name string) string {
|
||||
v := strings.TrimSpace(os.Getenv(name))
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(v, "https://") && !strings.HasPrefix(v, "http://") {
|
||||
slog.Warn("Rechtstext-URL ignoriert: nur http(s) erlaubt", "variable", name)
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -60,6 +99,7 @@ func Load() *Config {
|
||||
smtpPass := getEnv("SMTP_PASS", "")
|
||||
smtpFrom := getEnv("SMTP_FROM", "")
|
||||
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
|
||||
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
@@ -77,6 +117,13 @@ func Load() *Config {
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Legal: LegalTexts{
|
||||
ImprintURL: legalURL("LEGAL_IMPRINT_URL"),
|
||||
ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"),
|
||||
PrivacyURL: legalURL("LEGAL_PRIVACY_URL"),
|
||||
PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,31 @@ func NowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
var hashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||
// and words like "C#" are not tags. The browser uses the same rule (lib/markdown.tsx, lib/api.ts).
|
||||
var hashtagRegex = regexp.MustCompile(`(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
|
||||
// legacyHashtagRegex is the rule before anchors were excluded; only used to clean up stored tags.
|
||||
var legacyHashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
|
||||
// StaleAnchorTags returns tags the old rule found in content that the current rule does not, i.e. tags that
|
||||
// only came from URL fragments and similar.
|
||||
func StaleAnchorTags(content string) []string {
|
||||
current := map[string]bool{}
|
||||
for _, t := range ExtractTags(content) {
|
||||
current[t] = true
|
||||
}
|
||||
var stale []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range legacyHashtagRegex.FindAllStringSubmatch(content, -1) {
|
||||
t := strings.ToLower(m[1])
|
||||
if !current[t] && !seen[t] {
|
||||
seen[t] = true
|
||||
stale = append(stale, t)
|
||||
}
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
// ExtractTags extracts #hashtags from content
|
||||
func ExtractTags(content string) []string {
|
||||
@@ -78,7 +102,7 @@ func FormatGermanDateTime(isoStr string) string {
|
||||
time.May: "Mai",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "Aug.",
|
||||
time.August: "Aug.",
|
||||
time.September: "Sept.",
|
||||
time.October: "Okt.",
|
||||
time.November: "Nov.",
|
||||
@@ -107,20 +131,21 @@ type User struct {
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | admin
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | admin
|
||||
OrganizationID string `json:"organizationId,omitempty"`
|
||||
OrganizationName string `json:"organizationName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
UsedBy string `json:"usedBy,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -157,6 +182,7 @@ type Thought struct {
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
Assignees []string `json:"assignees"` // user IDs; only people with access to the task list
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
@@ -208,8 +234,6 @@ type TaskList struct {
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
@@ -224,12 +248,12 @@ type TaskList struct {
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Organization is defined by server admins; each user belongs to at most one.
|
||||
type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Teams []Team `json:"teams,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MemberCount int `json:"memberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
@@ -252,7 +276,7 @@ type TeamMember struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
|
||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||
AllowRegistration bool `json:"allowRegistration"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
@@ -261,6 +285,7 @@ type SystemSettings struct {
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPTLS bool `json:"smtpTls"`
|
||||
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
||||
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
|
||||
}
|
||||
|
||||
@@ -402,24 +427,36 @@ type ListItem struct {
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
CompletedBy string `json:"completedBy,omitempty"`
|
||||
CompletedByName string `json:"completedByName,omitempty"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedByName string `json:"createdByName,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CompletedAt string `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
Members []TaskListMember `json:"members,omitempty"` // owner + everyone the list is shared with
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// UserTheme is a custom CSS theme of one user.
|
||||
type UserTheme struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CSS string `json:"css"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTagsIgnoresURLAnchors(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"#start mitten #zwei": {"start", "zwei"},
|
||||
"siehe example.com/blog#ueberschrift": {},
|
||||
"https://example.com/blog#ueberschrift und #echt": {"echt"},
|
||||
"[Link](https://x.de/#anker) #tag": {"tag"},
|
||||
"(#inKlammern) C# ist kein Tag": {"inklammern"},
|
||||
"Zeile eins\n#zeilenanfang": {"zeilenanfang"},
|
||||
"#Doppelt #doppelt": {"doppelt"},
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := ExtractTags(in)
|
||||
if len(got) == 0 && len(want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ExtractTags(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleAnchorTags(t *testing.T) {
|
||||
got := StaleAnchorTags("example.com/a#teil und #teil sowie x.de/#nur")
|
||||
if !reflect.DeepEqual(got, []string{"nur"}) {
|
||||
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data.
|
||||
func (s *Store) ResetAndSeedDemo() error {
|
||||
if err := s.dropAllTables(); err != nil {
|
||||
return fmt.Errorf("reset demo: drop all tables: %w", err)
|
||||
}
|
||||
if err := s.migrate(); err != nil {
|
||||
return fmt.Errorf("reset demo: migrate schema: %w", err)
|
||||
}
|
||||
if err := s.EnsureDemoAccounts(); err != nil {
|
||||
return fmt.Errorf("reset demo: ensure demo accounts: %w", err)
|
||||
}
|
||||
slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
func TestResetAndSeedDemo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Create custom user and data
|
||||
hash, _ := auth.HashPassword("secret")
|
||||
customUser, err := store.CreateUserWithUsername("custom@test.local", "customuser", hash, "Custom", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateThought(customUser.ID, "A personal secret note", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Call ResetAndSeedDemo
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatalf("ResetAndSeedDemo failed: %v", err)
|
||||
}
|
||||
|
||||
// Custom user must be wiped
|
||||
u, err := store.GetUserByID(customUser.ID)
|
||||
if err == nil && u != nil {
|
||||
t.Fatalf("expected custom user to be wiped, but still found: %v", u)
|
||||
}
|
||||
|
||||
// Demo accounts admin and user must exist
|
||||
admin, err := store.GetUserByLogin("admin")
|
||||
if err != nil || admin == nil {
|
||||
t.Fatalf("expected demo admin to exist after reset: %v", err)
|
||||
}
|
||||
user, err := store.GetUserByLogin("user")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("expected demo user to exist after reset: %v", err)
|
||||
}
|
||||
|
||||
// Resetting again should work cleanly (idempotence on repeated server starts)
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatalf("second ResetAndSeedDemo failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutDemoModeDataIsNotWiped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "nodemo_test.db")
|
||||
|
||||
// 1. Initial start without demo mode
|
||||
store1, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
hash, _ := auth.HashPassword("secret123")
|
||||
user1, err := store1.CreateUserWithUsername("persistent@example.com", "persistent", hash, "Persistent", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithUsername failed: %v", err)
|
||||
}
|
||||
thought1, err := store1.CreateThought(user1.ID, "Important persistent note #keep", "high", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Close store simulating server shutdown
|
||||
store1.Close()
|
||||
|
||||
// 2. Server restart without demo mode (standard NewStore)
|
||||
store2, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second NewStore failed: %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
|
||||
// Verify existing data is preserved
|
||||
foundUser, err := store2.GetUserByID(user1.ID)
|
||||
if err != nil || foundUser == nil {
|
||||
t.Fatalf("expected user %s to be preserved without demo mode, err: %v", user1.ID, err)
|
||||
}
|
||||
if foundUser.Username != "persistent" {
|
||||
t.Fatalf("expected username 'persistent', got '%s'", foundUser.Username)
|
||||
}
|
||||
|
||||
thoughts, err := store2.GetThoughts(user1.ID, "", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetThoughts failed: %v", err)
|
||||
}
|
||||
if len(thoughts) != 1 || thoughts[0].ID != thought1.ID {
|
||||
t.Fatalf("expected thought %s to persist, got: %v", thought1.ID, thoughts)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
func newSyncTestStore(t *testing.T) (*Store, *domain.User) {
|
||||
t.Helper()
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
@@ -406,3 +407,22 @@ func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
|
||||
t.Fatalf("restart seeded again: %d notes", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnchorTagMigrationKeepsRealTags(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
th, err := s.CreateThought(u.ID, "Lesen: example.com/blog#ueberschrift #lesen", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(th.Tags) != 1 || th.Tags[0] != "lesen" {
|
||||
t.Fatalf("new thoughts must not get anchor tags: %v", th.Tags)
|
||||
}
|
||||
// State before the rule change: anchor stored as tag, plus a tag added by hand
|
||||
s.db.Exec(`UPDATE thoughts SET tags = '["ueberschrift","lesen","manuell"]' WHERE id = ?`, th.ID)
|
||||
s.db.Exec(`DELETE FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey)
|
||||
s.migrateAnchorTags()
|
||||
got, _ := s.GetThoughtByID(u.ID, th.ID)
|
||||
if len(got.Tags) != 2 || got.Tags[0] != "lesen" || got.Tags[1] != "manuell" {
|
||||
t.Fatalf("only the anchor tag must go: %v", got.Tags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// WriteMarkdownVault streams the user's workspace as an Obsidian-compatible ZIP straight into w:
|
||||
// notes as .md files in their folders (as this user sees them, shared ones included), task lists
|
||||
// under Aufgaben/, shopping and other lists under Listen/. Nothing is buffered on disk.
|
||||
func (s *Store) WriteMarkdownVault(userID string, w io.Writer) error {
|
||||
zw := zip.NewWriter(w)
|
||||
names := newVaultNames()
|
||||
now := time.Now()
|
||||
|
||||
// Folders first, so empty ones survive the round trip
|
||||
if folders, err := s.GetFolders(userID); err == nil {
|
||||
for _, f := range folders {
|
||||
if dir := names.dir(f); dir != "" {
|
||||
if _, err := zw.CreateHeader(&zip.FileHeader{Name: dir + "/", Modified: now}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docs, err := s.GetDocuments(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range docs {
|
||||
name := names.file(names.dir(d.FolderPath), d.Title, "Unbenannte Notiz")
|
||||
body := markdownFrontmatter(map[string]any{
|
||||
"title": d.Title, "icon": d.Icon, "tags": d.Tags, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt,
|
||||
}) + BlocksToVaultMarkdown(d.Blocks)
|
||||
if err := writeZipFile(zw, name, body, parseISO(d.UpdatedAt, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.writeVaultTasks(zw, userID, names, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeVaultLists(zw, userID, names, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
func (s *Store) writeVaultTasks(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error {
|
||||
tasks, err := s.GetTasks(userID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lists, _ := s.GetTaskLists(userID)
|
||||
byList := make(map[string][]domain.Thought)
|
||||
for _, t := range tasks {
|
||||
byList[t.TaskListID] = append(byList[t.TaskListID], t)
|
||||
}
|
||||
write := func(title string, tl *domain.TaskList, items []domain.Thought) error {
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].SortOrder < items[j].SortOrder })
|
||||
meta := map[string]any{"title": title}
|
||||
if tl != nil {
|
||||
meta["icon"], meta["created_at"], meta["updated_at"] = tl.Icon, tl.CreatedAt, tl.UpdatedAt
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(markdownFrontmatter(meta))
|
||||
for _, t := range items {
|
||||
sb.WriteString(taskMarkdownLine(t))
|
||||
}
|
||||
return writeZipFile(zw, names.file("Aufgaben", title, "Aufgaben"), sb.String(), now)
|
||||
}
|
||||
for i := range lists {
|
||||
if err := write(lists[i].Title, &lists[i], byList[lists[i].ID]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if items := byList[""]; len(items) > 0 {
|
||||
return write("Ohne Liste", nil, items)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeVaultLists(zw *zip.Writer, userID string, names *vaultNames, now time.Time) error {
|
||||
lists, err := s.GetAllListsForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range lists {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(markdownFrontmatter(map[string]any{"title": l.Title, "icon": l.Icon, "created_at": l.CreatedAt, "updated_at": l.UpdatedAt}))
|
||||
for _, it := range l.Items {
|
||||
mark := "[ ]"
|
||||
if it.IsCompleted {
|
||||
mark = "[x]"
|
||||
}
|
||||
line := "- " + mark + " " + oneLine(it.Content)
|
||||
if it.Quantity != "" {
|
||||
line += " (" + oneLine(it.Quantity) + ")"
|
||||
}
|
||||
if it.Category != "" && !strings.Contains(it.Content, "#") {
|
||||
line += " #" + strings.ReplaceAll(it.Category, " ", "_")
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
if err := writeZipFile(zw, names.file("Listen", l.Title, "Liste"), sb.String(), parseISO(l.UpdatedAt, now)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskMarkdownLine writes a task in the format of the Obsidian Tasks plugin (📅 due date, ⏫/🔺 priority)
|
||||
// with its subtasks and notes indented below.
|
||||
func taskMarkdownLine(t domain.Thought) string {
|
||||
mark := "[ ]"
|
||||
if t.IsCompleted {
|
||||
mark = "[x]"
|
||||
}
|
||||
line := "- " + mark + " " + oneLine(t.Content)
|
||||
switch t.Priority {
|
||||
case "urgent":
|
||||
line += " 🔺"
|
||||
case "high":
|
||||
line += " ⏫"
|
||||
case "low":
|
||||
line += " 🔽"
|
||||
}
|
||||
if t.DueDate != "" {
|
||||
line += " 📅 " + t.DueDate
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(line + "\n")
|
||||
for _, st := range t.Subtasks {
|
||||
sub := "[ ]"
|
||||
if st.IsCompleted {
|
||||
sub = "[x]"
|
||||
}
|
||||
sb.WriteString("\t- " + sub + " " + oneLine(st.Title) + "\n")
|
||||
}
|
||||
if notes := strings.TrimSpace(t.Notes); notes != "" {
|
||||
for _, l := range strings.Split(notes, "\n") {
|
||||
sb.WriteString("\t> " + strings.TrimRight(l, "\r") + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BlocksToVaultMarkdown turns outliner blocks into Markdown that Obsidian renders natively:
|
||||
// top-level headings, dividers and callouts as real block elements, everything else as a nested
|
||||
// list (one tab per level), code as fenced blocks inside the list.
|
||||
func BlocksToVaultMarkdown(blocks []domain.DocumentBlock) string {
|
||||
var sb strings.Builder
|
||||
// Children of a top-level heading/callout/code/divider become a list one level up; otherwise their
|
||||
// tab indent would turn them into an indented code block in Markdown
|
||||
lift := 0
|
||||
for _, b := range blocks {
|
||||
lvl := b.Level
|
||||
if lvl <= 0 {
|
||||
lvl, lift = 0, 0
|
||||
} else if lvl -= lift; lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
indent := strings.Repeat("\t", lvl)
|
||||
content := strings.ReplaceAll(b.Content, "\r\n", "\n")
|
||||
// Soft line breaks continue the list item, aligned under its text
|
||||
cont := func(text, prefix string) string {
|
||||
return strings.ReplaceAll(text, "\n", "\n"+indent+prefix)
|
||||
}
|
||||
|
||||
if b.Level <= 0 {
|
||||
switch b.Type {
|
||||
case "h1", "h2", "h3":
|
||||
hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type]
|
||||
sb.WriteString("\n" + hashes + " " + oneLine(content) + "\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "divider":
|
||||
sb.WriteString("\n---\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "callout":
|
||||
sb.WriteString("\n> [!note]\n> " + strings.ReplaceAll(content, "\n", "\n> ") + "\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
case "code":
|
||||
sb.WriteString("\n```" + b.Lang + "\n" + content + "\n```\n\n")
|
||||
lift = 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch b.Type {
|
||||
case "code":
|
||||
// Fenced block nested under the previous list item
|
||||
sb.WriteString(indent + "```" + b.Lang + "\n" + indent + strings.ReplaceAll(content, "\n", "\n"+indent) + "\n" + indent + "```\n")
|
||||
case "todo":
|
||||
mark := "[ ]"
|
||||
if b.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
sb.WriteString(indent + "- " + mark + " " + cont(content, " ") + "\n")
|
||||
case "h1", "h2", "h3":
|
||||
hashes := map[string]string{"h1": "#", "h2": "##", "h3": "###"}[b.Type]
|
||||
sb.WriteString(indent + "- " + hashes + " " + oneLine(content) + "\n")
|
||||
case "divider":
|
||||
sb.WriteString(indent + "- ---\n")
|
||||
case "callout":
|
||||
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
|
||||
default:
|
||||
if strings.TrimSpace(content) == "" {
|
||||
sb.WriteString(indent + "-\n")
|
||||
} else {
|
||||
sb.WriteString(indent + "- " + cont(content, " ") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// markdownFrontmatter writes YAML front matter; JSON-encoded strings are valid YAML scalars.
|
||||
func markdownFrontmatter(meta map[string]any) string {
|
||||
keys := make([]string, 0, len(meta))
|
||||
for k := range meta {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
order := map[string]int{"title": 0, "icon": 1, "tags": 2, "created_at": 3, "updated_at": 4}
|
||||
sort.Slice(keys, func(i, j int) bool { return order[keys[i]] < order[keys[j]] })
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
for _, k := range keys {
|
||||
switch v := meta[k].(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
q, _ := json.Marshal(v)
|
||||
sb.WriteString(k + ": " + string(q) + "\n")
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
sb.WriteString(k + ":\n")
|
||||
for _, t := range v {
|
||||
q, _ := json.Marshal(t)
|
||||
sb.WriteString(" - " + string(q) + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("---\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeZipFile(zw *zip.Writer, name, body string, modified time.Time) error {
|
||||
f, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modified})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(f, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ")
|
||||
}
|
||||
|
||||
func parseISO(v string, fallback time.Time) time.Time {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// vaultNames builds safe, unique file and folder names inside the archive.
|
||||
type vaultNames struct{ used map[string]bool }
|
||||
|
||||
func newVaultNames() *vaultNames { return &vaultNames{used: make(map[string]bool)} }
|
||||
|
||||
// segment makes one path part safe on Windows, macOS and Linux and never "." or "..".
|
||||
func segment(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r < 32 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(s))
|
||||
s = strings.Trim(s, ". ")
|
||||
if len([]rune(s)) > 100 {
|
||||
s = string([]rune(s)[:100])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (n *vaultNames) dir(folderPath string) string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range strings.Split(folderPath, "/") {
|
||||
if seg := segment(p); seg != "" {
|
||||
parts = append(parts, seg)
|
||||
}
|
||||
}
|
||||
return path.Join(parts...)
|
||||
}
|
||||
|
||||
// file returns "dir/title.md", adding " (2)", " (3)" … when the name is already taken.
|
||||
func (n *vaultNames) file(dir, title, fallback string) string {
|
||||
base := segment(title)
|
||||
if base == "" {
|
||||
base = fallback
|
||||
}
|
||||
name := path.Join(dir, base+".md")
|
||||
for i := 2; n.used[strings.ToLower(name)]; i++ {
|
||||
name = path.Join(dir, fmt.Sprintf("%s (%d).md", base, i))
|
||||
}
|
||||
n.used[strings.ToLower(name)] = true
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func TestBlocksToVaultMarkdown(t *testing.T) {
|
||||
md := BlocksToVaultMarkdown([]domain.DocumentBlock{
|
||||
{Type: "h2", Content: "Warum", Level: 0},
|
||||
{Type: "bullet", Content: "Strom sparen", Level: 1},
|
||||
{Type: "bullet", Content: "120 € im Jahr", Level: 2},
|
||||
{Type: "h1", Content: "Umzug", Level: 0},
|
||||
{Type: "bullet", Content: "Server", Level: 0},
|
||||
{Type: "todo", Content: "Backup ziehen", Checked: true, Level: 1},
|
||||
{Type: "bullet", Content: "Zeile 1\nZeile 2", Level: 1},
|
||||
{Type: "code", Content: "docker compose up -d", Lang: "bash", Level: 2},
|
||||
{Type: "divider", Level: 0},
|
||||
{Type: "code", Content: "echo hi", Lang: "sh", Level: 0},
|
||||
})
|
||||
want := []string{
|
||||
// children of a heading start a list at the margin, not an indented code block
|
||||
"## Warum\n\n- Strom sparen\n\t- 120 € im Jahr\n",
|
||||
"# Umzug\n\n- Server\n",
|
||||
"\t- [x] Backup ziehen\n",
|
||||
"\t- Zeile 1\n\t Zeile 2\n",
|
||||
"\t\t```bash\n\t\tdocker compose up -d\n\t\t```\n",
|
||||
"\n---\n\n",
|
||||
"```sh\necho hi\n```\n",
|
||||
}
|
||||
for _, w := range want {
|
||||
if !strings.Contains(md, w) {
|
||||
t.Errorf("missing %q in:\n%s", w, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMarkdownVault(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "vault.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
openSharing(t, store)
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
|
||||
if _, err := store.CreateFolder(anna.ID, "Projekte/Leer"); err != nil {
|
||||
t.Fatalf("CreateFolder: %v", err)
|
||||
}
|
||||
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Inhalt", Level: 0}}
|
||||
// Note tags come from #hashtags in the text
|
||||
tagged := []domain.DocumentBlock{{ID: "b0", Type: "bullet", Content: "Inhalt #server", Level: 0}}
|
||||
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", tagged, nil, false)
|
||||
_, _ = store.CreateDocument(anna.ID, "", "Homelab", "🖥️", "Projekte", blocks, nil, false)
|
||||
_, _ = store.CreateDocument(anna.ID, "", "../../etc/passwd", "", "", blocks, nil, false)
|
||||
shared, _ := store.CreateDocument(ben.ID, "", "Von Ben", "", "", blocks, nil, false)
|
||||
_, _ = store.CreateDocument(ben.ID, "", "Bens Geheimnis", "", "", blocks, nil, false)
|
||||
if _, err := store.CreateDocShare(ben.ID, shared.ID, anna.ID, "read"); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
|
||||
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
|
||||
task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster putzen", "high", "manual", "2026-11-02", tl.ID, "todo", "")
|
||||
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "2026-11-02", tl.ID)
|
||||
subs := []domain.SubTask{{ID: "s1", Title: "Leiter holen", IsCompleted: true}}
|
||||
_, _ = store.UpdateThoughtDetails(anna.ID, task.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
|
||||
list, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping")
|
||||
_, _ = store.CreateListItem(list.ID, "Milch", "Kühlregal", "2L", 0, anna.ID)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := store.WriteMarkdownVault(anna.ID, &buf); err != nil {
|
||||
t.Fatalf("WriteMarkdownVault: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("not a zip: %v", err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
for _, part := range strings.Split(f.Name, "/") {
|
||||
if part == ".." || part == "." || strings.HasPrefix(f.Name, "/") {
|
||||
t.Errorf("unsafe path in archive: %q", f.Name)
|
||||
}
|
||||
}
|
||||
rc, _ := f.Open()
|
||||
data, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
files[f.Name] = string(data)
|
||||
}
|
||||
|
||||
for _, name := range []string{"Projekte/Leer/", "Projekte/Homelab.md", "Projekte/Homelab (2).md", "Von Ben.md", "Aufgaben/Haushalt.md", "Listen/Einkauf.md"} {
|
||||
if _, ok := files[name]; !ok {
|
||||
t.Errorf("missing %q; archive has %v", name, keys(files))
|
||||
}
|
||||
}
|
||||
for name := range files {
|
||||
if strings.Contains(name, "Geheimnis") {
|
||||
t.Errorf("Ben's unshared note must not be exported: %q", name)
|
||||
}
|
||||
}
|
||||
tagged1, tagged2 := files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"]
|
||||
if !strings.Contains(tagged1+tagged2, "---\ntitle: \"Homelab\"\nicon: \"🖥️\"\ntags:\n - \"server\"\n") {
|
||||
t.Errorf("frontmatter wrong:\n%s\n--- (2):\n%s", files["Projekte/Homelab.md"], files["Projekte/Homelab (2).md"])
|
||||
}
|
||||
if want := "- [ ] Fenster putzen ⏫ 📅 2026-11-02\n\t- [x] Leiter holen\n"; !strings.Contains(files["Aufgaben/Haushalt.md"], want) {
|
||||
t.Errorf("task line wrong:\n%s", files["Aufgaben/Haushalt.md"])
|
||||
}
|
||||
if !strings.Contains(files["Listen/Einkauf.md"], "- [ ] Milch (2L) #Kühlregal") {
|
||||
t.Errorf("list item wrong:\n%s", files["Listen/Einkauf.md"])
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// SearchHit represents a single ranked match across documents, tasks/thoughts, and lists.
|
||||
type SearchHit struct {
|
||||
Type string `json:"type"` // "document", "thought", "list_item"
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
ListID string `json:"listId,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// SearchResults encapsulates all results for backwards-compatible API serialization.
|
||||
type SearchResults struct {
|
||||
Thoughts []domain.Thought `json:"thoughts"`
|
||||
Documents []domain.Document `json:"documents"`
|
||||
ListItems []domain.ListItem `json:"listItems"`
|
||||
Hits []SearchHit `json:"hits"`
|
||||
}
|
||||
|
||||
var wordTokenRegex = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||
|
||||
// PrepareFTSQuery extracts valid words from user input and formats them as prefix match tokens.
|
||||
// E.g. "einkau" -> `"einkau"*`, "Hello World!" -> `"Hello"* "World"*`.
|
||||
func PrepareFTSQuery(raw string) string {
|
||||
matches := wordTokenRegex.FindAllString(strings.TrimSpace(raw), -1)
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
tokens := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
clean := strings.ReplaceAll(m, `"`, `""`)
|
||||
tokens = append(tokens, fmt.Sprintf(`"%s"*`, clean))
|
||||
}
|
||||
return strings.Join(tokens, " ")
|
||||
}
|
||||
|
||||
// migrateSearchFTS creates the virtual FTS5 tables and synchronization triggers.
|
||||
func (s *Store) migrateSearchFTS() {
|
||||
tables := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS thoughts_fts USING fts5(
|
||||
thought_id UNINDEXED,
|
||||
content,
|
||||
notes,
|
||||
subtasks,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||
doc_id UNINDEXED,
|
||||
block_id UNINDEXED,
|
||||
title,
|
||||
content,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS list_items_fts USING fts5(
|
||||
item_id UNINDEXED,
|
||||
list_id UNINDEXED,
|
||||
content,
|
||||
category,
|
||||
tokenize='unicode61'
|
||||
);`,
|
||||
}
|
||||
|
||||
for _, stmt := range tables {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
slog.Error("Failed to create FTS virtual table", "error", err, "statement", stmt)
|
||||
}
|
||||
}
|
||||
|
||||
triggers := []string{
|
||||
// Thoughts triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ai AFTER INSERT ON thoughts BEGIN
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_au AFTER UPDATE ON thoughts BEGIN
|
||||
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
VALUES (new.id, new.content, new.notes, new.subtasks);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_thoughts_ad AFTER DELETE ON thoughts BEGIN
|
||||
DELETE FROM thoughts_fts WHERE thought_id = old.id;
|
||||
END;`,
|
||||
|
||||
// Documents triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
VALUES (new.id, '', new.title, '');
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_au AFTER UPDATE OF title, is_archived ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id AND block_id = '';
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.id, '', new.title, '' WHERE new.is_archived = 0;
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_documents_ad AFTER DELETE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.id;
|
||||
END;`,
|
||||
|
||||
// Blocks triggers (excluding tombstones/deleted blocks)
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ai AFTER INSERT ON blocks BEGIN
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.document_id, new.id, '', new.content
|
||||
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_au AFTER UPDATE OF content, deleted, deleted_at, document_id ON blocks BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT new.document_id, new.id, '', new.content
|
||||
WHERE new.deleted = 0 AND (new.deleted_at = '' OR new.deleted_at IS NULL);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_blocks_ad AFTER DELETE ON blocks BEGIN
|
||||
DELETE FROM documents_fts WHERE doc_id = old.document_id AND block_id = old.id;
|
||||
END;`,
|
||||
|
||||
// List items triggers
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ai AFTER INSERT ON list_items BEGIN
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
VALUES (new.id, new.list_id, new.content, new.category);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_au AFTER UPDATE ON list_items BEGIN
|
||||
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
VALUES (new.id, new.list_id, new.content, new.category);
|
||||
END;`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_list_items_ad AFTER DELETE ON list_items BEGIN
|
||||
DELETE FROM list_items_fts WHERE item_id = old.id;
|
||||
END;`,
|
||||
}
|
||||
|
||||
for _, trg := range triggers {
|
||||
if _, err := s.db.Exec(trg); err != nil {
|
||||
slog.Error("Failed to create FTS trigger", "error", err, "statement", trg)
|
||||
}
|
||||
}
|
||||
|
||||
s.backfillSearchFTS()
|
||||
}
|
||||
|
||||
// backfillSearchFTS populates empty FTS indices once on startup.
|
||||
func (s *Store) backfillSearchFTS() {
|
||||
var thoughtCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM thoughts_fts`).Scan(&thoughtCount)
|
||||
if thoughtCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO thoughts_fts(thought_id, content, notes, subtasks)
|
||||
SELECT id, content, notes, subtasks FROM thoughts;
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill thoughts_fts", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
var docCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM documents_fts`).Scan(&docCount)
|
||||
if docCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT id, '', title, '' FROM documents WHERE is_archived = 0;
|
||||
|
||||
INSERT INTO documents_fts(doc_id, block_id, title, content)
|
||||
SELECT document_id, id, '', content FROM blocks
|
||||
WHERE deleted = 0 AND (deleted_at = '' OR deleted_at IS NULL);
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill documents_fts", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
var listCount int
|
||||
_ = s.db.QueryRow(`SELECT count(*) FROM list_items_fts`).Scan(&listCount)
|
||||
if listCount == 0 {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO list_items_fts(item_id, list_id, content, category)
|
||||
SELECT id, list_id, content, category FROM list_items;
|
||||
`)
|
||||
if err != nil {
|
||||
slog.Error("Failed to backfill list_items_fts", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SearchGlobal executes an FTS5 full-text search across documents, thoughts/tasks and list items,
|
||||
// filtering results strictly to what the user is authorized to view.
|
||||
func (s *Store) SearchGlobal(userID, query string) (*SearchResults, error) {
|
||||
ftsQuery := PrepareFTSQuery(query)
|
||||
res := &SearchResults{
|
||||
Thoughts: make([]domain.Thought, 0),
|
||||
Documents: make([]domain.Document, 0),
|
||||
ListItems: make([]domain.ListItem, 0),
|
||||
Hits: make([]SearchHit, 0),
|
||||
}
|
||||
|
||||
if ftsQuery == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// 1. Search Documents and Blocks
|
||||
// Weights: 10.0 for title, 1.0 for block content
|
||||
docRows, err := s.db.Query(`
|
||||
SELECT f.doc_id, f.block_id,
|
||||
snippet(documents_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(documents_fts, 10.0, 1.0) AS score,
|
||||
d.title, d.icon, d.user_id, d.is_archived
|
||||
FROM documents_fts f
|
||||
JOIN documents d ON d.id = f.doc_id
|
||||
WHERE documents_fts MATCH ? AND d.is_archived = 0
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery)
|
||||
if err == nil {
|
||||
seenDocIDs := make(map[string]bool)
|
||||
for docRows.Next() {
|
||||
var docID, blockID, snip, title, icon, ownerID string
|
||||
var score float64
|
||||
var isArchived int
|
||||
if scanErr := docRows.Scan(&docID, &blockID, &snip, &score, &title, &icon, &ownerID, &isArchived); scanErr == nil {
|
||||
// Access check via DocRole
|
||||
if s.DocRole(userID, docID) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
hitID := docID
|
||||
if blockID != "" {
|
||||
hitID = docID + ":" + blockID
|
||||
}
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "document",
|
||||
ID: hitID,
|
||||
Title: title,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
DocID: docID,
|
||||
BlockID: blockID,
|
||||
Icon: icon,
|
||||
})
|
||||
|
||||
if !seenDocIDs[docID] {
|
||||
seenDocIDs[docID] = true
|
||||
if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil {
|
||||
res.Documents = append(res.Documents, *doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = docRows.Close()
|
||||
}
|
||||
|
||||
// 2. Search Thoughts & Tasks
|
||||
// Weights: 5.0 for content, 1.0 for notes, 1.0 for subtasks
|
||||
thoughtRows, err := s.db.Query(`
|
||||
SELECT f.thought_id,
|
||||
snippet(thoughts_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(thoughts_fts, 5.0, 1.0, 1.0) AS score,
|
||||
t.id, t.user_id, t.content, t.tags, t.priority, t.source, t.status,
|
||||
t.is_completed, t.converted_type, t.converted_ref_id, t.due_date,
|
||||
t.task_list_id, t.kanban_status, t.notes, t.alarm_sent, t.created_at,
|
||||
t.triaged_at, t.subtasks, t.sort_order
|
||||
FROM thoughts_fts f
|
||||
JOIN thoughts t ON t.id = f.thought_id
|
||||
WHERE thoughts_fts MATCH ?
|
||||
AND (t.user_id = ? OR t.task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery, userID, userID, userID, userID)
|
||||
if err == nil {
|
||||
for thoughtRows.Next() {
|
||||
var thoughtID, snip string
|
||||
var score float64
|
||||
var t domain.Thought
|
||||
var tagsStr, subtasksStr string
|
||||
var isCompleted, alarmSent int
|
||||
var triagedAt, dueDate, convertedType, convertedRefId sql.NullString
|
||||
if scanErr := thoughtRows.Scan(
|
||||
&thoughtID, &snip, &score,
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source, &t.Status,
|
||||
&isCompleted, &convertedType, &convertedRefId, &dueDate,
|
||||
&t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent, &t.CreatedAt,
|
||||
&triagedAt, &subtasksStr, &t.SortOrder,
|
||||
); scanErr == nil {
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
t.DueDate = dueDate.String
|
||||
t.ConvertedType = convertedType.String
|
||||
t.ConvertedRefID = convertedRefId.String
|
||||
t.TriagedAt = triagedAt.String
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "thought",
|
||||
ID: t.ID,
|
||||
Title: t.Content,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
})
|
||||
res.Thoughts = append(res.Thoughts, t)
|
||||
}
|
||||
}
|
||||
_ = thoughtRows.Close()
|
||||
}
|
||||
|
||||
// 3. Search List Items
|
||||
// Weights: 2.0 for content, 1.0 for category
|
||||
accessibleLists, _ := s.GetAllListsForUser(userID)
|
||||
accessibleListIDs := make(map[string]bool, len(accessibleLists))
|
||||
for _, l := range accessibleLists {
|
||||
accessibleListIDs[l.ID] = true
|
||||
}
|
||||
|
||||
if len(accessibleListIDs) > 0 {
|
||||
itemRows, err := s.db.Query(`
|
||||
SELECT f.item_id, f.list_id,
|
||||
snippet(list_items_fts, -1, '<mark>', '</mark>', '…', 14) AS snip,
|
||||
bm25(list_items_fts, 2.0, 1.0) AS score,
|
||||
li.id, li.list_id, li.content, li.category, li.quantity,
|
||||
li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.sort_order, li.created_at, li.completed_at
|
||||
FROM list_items_fts f
|
||||
JOIN list_items li ON li.id = f.item_id
|
||||
WHERE list_items_fts MATCH ?
|
||||
ORDER BY score ASC
|
||||
LIMIT 40
|
||||
`, ftsQuery)
|
||||
if err == nil {
|
||||
for itemRows.Next() {
|
||||
var itemID, listID, snip string
|
||||
var score float64
|
||||
var li domain.ListItem
|
||||
var isCompleted int
|
||||
if scanErr := itemRows.Scan(
|
||||
&itemID, &listID, &snip, &score,
|
||||
&li.ID, &li.ListID, &li.Content, &li.Category, &li.Quantity,
|
||||
&isCompleted, &li.CompletedBy, &li.CompletedByName,
|
||||
&li.SortOrder, &li.CreatedAt, &li.CompletedAt,
|
||||
); scanErr == nil {
|
||||
if !accessibleListIDs[listID] {
|
||||
continue
|
||||
}
|
||||
li.IsCompleted = isCompleted != 0
|
||||
|
||||
res.Hits = append(res.Hits, SearchHit{
|
||||
Type: "list_item",
|
||||
ID: li.ID,
|
||||
Title: li.Content,
|
||||
Snippet: snip,
|
||||
Score: score,
|
||||
ListID: listID,
|
||||
})
|
||||
res.ListItems = append(res.ListItems, li)
|
||||
}
|
||||
}
|
||||
_ = itemRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func setupTestStore(t *testing.T) (*Store, *domain.User, *domain.User) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test_search.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
openSharing(t, store)
|
||||
|
||||
userA, err := store.CreateUserWithUsername("alice@test.local", "alice", "hash123", "Alice", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser Alice failed: %v", err)
|
||||
}
|
||||
|
||||
userB, err := store.CreateUserWithUsername("bob@test.local", "bob", "hash123", "Bob", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser Bob failed: %v", err)
|
||||
}
|
||||
|
||||
return store, userA, userB
|
||||
}
|
||||
|
||||
func TestSearchBlockContent(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Create document with block content
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Rezeptbuch", "📖", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
blockID := domain.NewID("blk")
|
||||
pos := 1.0
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||
VALUES (?, ?, ?, 0, ?, 'bullet', 0, '')
|
||||
`, blockID, doc.ID, "Zutaten: 500g Mehl und Hefe für Pizza", pos)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert block failed: %v", err)
|
||||
}
|
||||
|
||||
// Search for block content
|
||||
res, err := store.SearchGlobal(userA.ID, "Hefe")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal failed: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, hit := range res.Hits {
|
||||
if hit.DocID == doc.ID && hit.BlockID == blockID {
|
||||
found = true
|
||||
if hit.Snippet == "" {
|
||||
t.Errorf("expected snippet with marked match, got empty string")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected to find hit for block content 'Hefe', hits: %+v", res.Hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTitleRanking(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Doc 1 has "Architektur" in block content
|
||||
docContent, err := store.CreateDocument(userA.ID, "", "Notizen zu Systemen", "📝", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
_, err = store.db.Exec(`
|
||||
INSERT INTO blocks (id, document_id, content, level, position, type, deleted, deleted_at)
|
||||
VALUES (?, ?, 'Hier wird die Architektur im Detail besprochen', 0, 1.0, 'bullet', 0, '')
|
||||
`, domain.NewID("blk"), docContent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert block failed: %v", err)
|
||||
}
|
||||
|
||||
// Doc 2 has "Architektur" in title
|
||||
docTitle, err := store.CreateDocument(userA.ID, "", "Architektur Leitfaden", "🏛️", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
res, err := store.SearchGlobal(userA.ID, "Architektur")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal failed: %v", err)
|
||||
}
|
||||
|
||||
if len(res.Hits) < 2 {
|
||||
t.Fatalf("expected at least 2 hits, got %d", len(res.Hits))
|
||||
}
|
||||
|
||||
// First hit must be the document with the title match
|
||||
if res.Hits[0].DocID != docTitle.ID {
|
||||
t.Errorf("expected title match (doc %s) to rank first, got doc %s", docTitle.ID, res.Hits[0].DocID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchPermissionsAndUnshared(t *testing.T) {
|
||||
store, userA, userB := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
// Alice creates private doc, private thought, and private list
|
||||
privateDoc, err := store.CreateDocument(userA.ID, "", "Geheimes Projekt", "🔒", "", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
privateThought, err := store.CreateThought(userA.ID, "Geheimer Gedanke für Alice", "high", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
privateList, err := store.CreateList(userA.ID, "Geheime Einkaufsliste", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateListItem(privateList.ID, "Geheimer Kuchen", "", "", 0, userA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Bob searches for "Geheim"
|
||||
resBob, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Bob failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resBob.Hits) != 0 || len(resBob.Thoughts) != 0 || len(resBob.Documents) != 0 || len(resBob.ListItems) != 0 {
|
||||
t.Fatalf("expected Bob to find 0 private hits of Alice, got hits=%+v thoughts=%+v docs=%+v",
|
||||
resBob.Hits, resBob.Thoughts, resBob.Documents)
|
||||
}
|
||||
|
||||
// Now share privateDoc with Bob
|
||||
if _, err := store.CreateDocShare(userA.ID, privateDoc.ID, userB.Email, "read"); err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// Bob searches again
|
||||
resBobShared, err := store.SearchGlobal(userB.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Bob after share failed: %v", err)
|
||||
}
|
||||
|
||||
if len(resBobShared.Documents) != 1 || resBobShared.Documents[0].ID != privateDoc.ID {
|
||||
t.Fatalf("expected Bob to find shared document %s, got %+v", privateDoc.ID, resBobShared.Documents)
|
||||
}
|
||||
|
||||
// Alice searches and finds her own items
|
||||
resAlice, err := store.SearchGlobal(userA.ID, "Geheim")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal for Alice failed: %v", err)
|
||||
}
|
||||
if len(resAlice.Documents) != 1 || len(resAlice.Thoughts) != 1 || len(resAlice.ListItems) != 1 {
|
||||
t.Fatalf("expected Alice to find her items, got docs=%d thoughts=%d lists=%d",
|
||||
len(resAlice.Documents), len(resAlice.Thoughts), len(resAlice.ListItems))
|
||||
}
|
||||
_ = privateThought
|
||||
}
|
||||
|
||||
func TestSearchPrefixAndSpecialCharacters(t *testing.T) {
|
||||
store, userA, _ := setupTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
_, err := store.CreateThought(userA.ID, "Großer Wocheneinkauf im Supermarkt", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
// Prefix search: "einkau" should find "Wocheneinkauf" or "Einkauf"
|
||||
// Note: FTS5 prefix matches word prefixes: "Wochen..." vs "einkauf"
|
||||
_, err = store.CreateThought(userA.ID, "Einkaufszettel schreiben", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThought failed: %v", err)
|
||||
}
|
||||
|
||||
res, err := store.SearchGlobal(userA.ID, "einkau")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchGlobal prefix failed: %v", err)
|
||||
}
|
||||
if len(res.Hits) == 0 {
|
||||
t.Errorf("expected prefix match for 'einkau', got 0 hits")
|
||||
}
|
||||
|
||||
// Special characters should not cause syntax errors
|
||||
crazyQueries := []string{
|
||||
`"unclosed quote`,
|
||||
`test: foo AND OR NOT (bar)^`,
|
||||
`!@#$%^&*()_+=-[]{}\|;:'",.<>/?`,
|
||||
`* * *`,
|
||||
``,
|
||||
` `,
|
||||
}
|
||||
for _, cq := range crazyQueries {
|
||||
_, err := store.SearchGlobal(userA.ID, cq)
|
||||
if err != nil {
|
||||
t.Errorf("query %q caused error: %v", cq, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
{"Spülmaschinentabs", "Drogerie", "1 Pack"},
|
||||
}
|
||||
for i, it := range items {
|
||||
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i)
|
||||
item, err := s.CreateListItem(shop.ID, it.content, it.category, it.qty, i, user.ID)
|
||||
if err == nil && (i == 1 || i == 6 || i == 8) {
|
||||
_, _ = s.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (s *Store) SeedStarterContent(user *domain.User) error {
|
||||
{"Erste-Hilfe-Set", "Ausrüstung"}, {"Stirnlampe", "Ausrüstung"}, {"Trinkflaschen", "Ausrüstung"},
|
||||
{"Personalausweise", "Dokumente"}, {"Hüttenschlafsack", "Ausrüstung"},
|
||||
} {
|
||||
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i)
|
||||
_, _ = s.CreateListItem(packing.ID, it.content, it.category, "", i, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "test_mindsift.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -54,7 +55,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0)
|
||||
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateListItem failed: %v", err)
|
||||
}
|
||||
@@ -106,7 +107,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 6. Test Organizations & Teams
|
||||
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
|
||||
org, err := store.CreateOrganization("Mindsift Inc")
|
||||
if err != nil || org.ID == "" {
|
||||
t.Fatalf("CreateOrganization failed: %v", err)
|
||||
}
|
||||
@@ -138,7 +139,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
t.Fatalf("GetSystemSettings failed: %v", err)
|
||||
}
|
||||
sysSettings.SharingMode = "teams_only"
|
||||
sysSettings.SharingMode = SharingOpen
|
||||
err = store.UpdateSystemSettings(sysSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
||||
@@ -179,7 +180,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
@@ -236,6 +237,7 @@ func TestPerUserDocPinning(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "pin_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -312,6 +314,7 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -368,6 +371,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -441,6 +445,7 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "tags_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -542,6 +547,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "test_db_first.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -692,6 +698,7 @@ func TestBlockListAndDerivedData(t *testing.T) {
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
openSharing(t, store)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
@@ -752,4 +759,18 @@ func TestBlockListAndDerivedData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// openSharing lets tests that are not about visibility share freely between their users.
|
||||
func openSharing(t *testing.T, store *Store) {
|
||||
t.Helper()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
sys, err := store.GetSystemSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSystemSettings: %v", err)
|
||||
}
|
||||
sys.SharingMode = SharingOpen
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatalf("UpdateSystemSettings: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
const tagsAnchorMigrationKey = "migration_tags_without_anchors"
|
||||
|
||||
// migrateAnchorTags removes tags that only came from URL fragments (example.com/blog#ueberschrift) since the
|
||||
// hashtag rule now requires whitespace or "(" before "#". Runs once. Only such tags are removed from
|
||||
// thoughts and notes, so tags users added by hand stay; block_tags is rebuilt from the blocks.
|
||||
func (s *Store) migrateAnchorTags() {
|
||||
var done string
|
||||
if s.db.QueryRow(`SELECT value FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey).Scan(&done) == nil {
|
||||
return
|
||||
}
|
||||
cleaned := 0
|
||||
for _, table := range []struct{ name, text string }{{"thoughts", "content"}, {"documents", "content"}} {
|
||||
rows, err := s.db.Query(`SELECT id, ` + table.text + `, tags FROM ` + table.name + ` WHERE ` + table.text + ` LIKE '%#%'`)
|
||||
if err != nil {
|
||||
slog.Error("Bereinigung der Hashtags fehlgeschlagen", "table", table.name, "error", err)
|
||||
return
|
||||
}
|
||||
type row struct{ id, text, tags string }
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if rows.Scan(&r.id, &r.text, &r.tags) == nil {
|
||||
list = append(list, r)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, r := range list {
|
||||
stale := domain.StaleAnchorTags(r.text)
|
||||
if len(stale) == 0 {
|
||||
continue
|
||||
}
|
||||
drop := map[string]bool{}
|
||||
for _, t := range stale {
|
||||
drop[t] = true
|
||||
}
|
||||
var tags []string
|
||||
if json.Unmarshal([]byte(r.tags), &tags) != nil {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if !drop[t] {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) != len(tags) {
|
||||
out, _ := json.Marshal(kept)
|
||||
if _, err := s.db.Exec(`UPDATE `+table.name+` SET tags = ? WHERE id = ?`, string(out), r.id); err == nil {
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.reindexAllDocBlockTags(); err != nil {
|
||||
slog.Error("Neuaufbau der Block-Hashtags fehlgeschlagen", "error", err)
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, 'done')`, tagsAnchorMigrationKey)
|
||||
if cleaned > 0 {
|
||||
slog.Info("Hashtags aus URL-Ankern entfernt", "entries", cleaned)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskAssigneesFollowListAccess(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "assignees.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
owner, _ := store.EnsureDefaultUser()
|
||||
direct, _ := store.CreateUser("direct@example.com", "hash", "Direkt", "member")
|
||||
viaTeam, _ := store.CreateUser("team@example.com", "hash", "Team", "member")
|
||||
stranger, _ := store.CreateUser("stranger@example.com", "hash", "Fremd", "member")
|
||||
|
||||
tl, err := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
task, err := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", tl.ID, "todo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThoughtWithDetails failed: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(owner.ID, task.ID, "keep", "task", "", "", tl.ID); err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage failed: %v", err)
|
||||
}
|
||||
|
||||
// Everyone but the stranger shares the organization, so they can see each other
|
||||
org, _ := store.CreateOrganization("Familie")
|
||||
for _, u := range []string{owner.ID, direct.ID, viaTeam.ID} {
|
||||
if err := store.SetUserOrganization(u, org.ID); err != nil {
|
||||
t.Fatalf("SetUserOrganization failed: %v", err)
|
||||
}
|
||||
}
|
||||
team, err := store.CreateTeam(org.ID, "WG", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTeam failed: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, owner.ID, "admin")
|
||||
_ = store.AddTeamMember(team.ID, viaTeam.ID, "member")
|
||||
if _, err := store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare user failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "viewer", owner.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{stranger.ID}); !errors.Is(err, ErrAssigneeNoAccess) {
|
||||
t.Fatalf("assigning someone without access must fail, got %v", err)
|
||||
}
|
||||
if _, err := store.SetTaskAssignees(viaTeam.ID, task.ID, []string{viaTeam.ID}); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not assign, got %v", err)
|
||||
}
|
||||
got, err := store.SetTaskAssignees(direct.ID, task.ID, []string{owner.ID, direct.ID, viaTeam.ID, direct.ID})
|
||||
if err != nil || len(got) != 3 {
|
||||
t.Fatalf("expected 3 assignees, got %v (%v)", got, err)
|
||||
}
|
||||
|
||||
tasks, _ := store.GetTasks(owner.ID, tl.ID)
|
||||
if len(tasks) != 1 || len(tasks[0].Assignees) != 3 {
|
||||
t.Fatalf("GetTasks should carry assignees, got %+v", tasks)
|
||||
}
|
||||
|
||||
// Leaving the team removes the team member's assignment
|
||||
if err := store.RemoveTeamMember(team.ID, viaTeam.ID); err != nil {
|
||||
t.Fatalf("RemoveTeamMember failed: %v", err)
|
||||
}
|
||||
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
|
||||
|
||||
// Removing the direct share removes that user's assignment, the owner stays
|
||||
if err := store.RemoveTaskListShare(tl.ID, direct.ID); err != nil {
|
||||
t.Fatalf("RemoveTaskListShare failed: %v", err)
|
||||
}
|
||||
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
|
||||
|
||||
// Access through a second path keeps the assignment
|
||||
_ = store.AddTeamMember(team.ID, direct.ID, "member")
|
||||
_, _ = store.AddTaskListShare(tl.ID, "user", direct.ID, "editor", owner.ID)
|
||||
if _, err := store.SetTaskAssignees(owner.ID, task.ID, []string{owner.ID, direct.ID}); err != nil {
|
||||
t.Fatalf("re-assign failed: %v", err)
|
||||
}
|
||||
_ = store.RemoveTaskListShare(tl.ID, direct.ID)
|
||||
assertAssignees(t, store, owner.ID, task.ID, owner.ID, direct.ID)
|
||||
|
||||
// Deleting the team takes the last path away
|
||||
if err := store.DeleteTeam(team.ID); err != nil {
|
||||
t.Fatalf("DeleteTeam failed: %v", err)
|
||||
}
|
||||
assertAssignees(t, store, owner.ID, task.ID, owner.ID)
|
||||
|
||||
// Moving the task out of the list drops all assignments
|
||||
empty := ""
|
||||
if _, err := store.UpdateThoughtDetails(owner.ID, task.ID, nil, nil, nil, nil, &empty, nil, nil, nil, nil); err != nil {
|
||||
t.Fatalf("UpdateThoughtDetails failed: %v", err)
|
||||
}
|
||||
assertAssignees(t, store, owner.ID, task.ID)
|
||||
}
|
||||
|
||||
func assertAssignees(t *testing.T, store *Store, viewerID, taskID string, want ...string) {
|
||||
t.Helper()
|
||||
th, err := store.GetThoughtByID(viewerID, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetThoughtByID failed: %v", err)
|
||||
}
|
||||
if len(th.Assignees) != len(want) {
|
||||
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, id := range th.Assignees {
|
||||
have[id] = true
|
||||
}
|
||||
for _, id := range want {
|
||||
if !have[id] {
|
||||
t.Fatalf("expected assignees %v, got %v", want, th.Assignees)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The open-task counter (nav badge, "Alle Aufgaben") counts what "Alle Aufgaben" shows,
|
||||
// including tasks others created in a shared list
|
||||
func TestDashboardCountsTasksOfSharedLists(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "stats.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
openSharing(t, store)
|
||||
owner, _ := store.CreateUser("owner@example.com", "hash", "Owner", "member")
|
||||
member, _ := store.CreateUser("member@example.com", "hash", "Member", "member")
|
||||
tl, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "")
|
||||
if _, err := store.AddTaskListShare(tl.ID, "user", member.ID, "editor", owner.ID); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
for i, title := range []string{"Bad putzen", "Müll raus"} {
|
||||
th, _ := store.CreateThoughtWithDetails(owner.ID, title, "normal", "manual", "", tl.ID, "todo", "")
|
||||
_, _ = store.UpdateThoughtTriage(owner.ID, th.ID, "keep", "task", "", "", tl.ID)
|
||||
if i == 1 {
|
||||
done := true
|
||||
_, _ = store.UpdateThoughtDetails(owner.ID, th.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
}
|
||||
own, _ := store.CreateThoughtWithDetails(member.ID, "Eigene Aufgabe", "normal", "manual", "", "", "todo", "")
|
||||
_, _ = store.UpdateThoughtTriage(member.ID, own.ID, "keep", "task", "", "", "")
|
||||
|
||||
st, err := store.GetDashboardStats(member.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardStats: %v", err)
|
||||
}
|
||||
tasks, _ := store.GetTasks(member.ID, "")
|
||||
open := 0
|
||||
for _, task := range tasks {
|
||||
if !task.IsCompleted {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if st.TasksOpenCount != 2 || st.TasksOpenCount != open || st.TasksDoneCount != 1 {
|
||||
t.Fatalf("expected 2 open / 1 done matching GetTasks (%d open), got %d / %d", open, st.TasksOpenCount, st.TasksDoneCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// MaxThemeCSSBytes limits a custom theme; the shipped themes are around 3 KB.
|
||||
const MaxThemeCSSBytes = 200 * 1024
|
||||
|
||||
// GetUserThemes returns the user's own CSS themes, oldest first.
|
||||
func (s *Store) GetUserThemes(userID string) ([]domain.UserTheme, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, css_content, created_at, updated_at FROM user_themes WHERE user_id = ? ORDER BY created_at ASC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return []domain.UserTheme{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
themes := make([]domain.UserTheme, 0)
|
||||
for rows.Next() {
|
||||
var t domain.UserTheme
|
||||
if rows.Scan(&t.ID, &t.Name, &t.CSS, &t.CreatedAt, &t.UpdatedAt) == nil {
|
||||
themes = append(themes, t)
|
||||
}
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
// SaveUserTheme creates a theme (empty themeID) or updates one of the user's themes.
|
||||
func (s *Store) SaveUserTheme(userID, themeID, name, css string) (*domain.UserTheme, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("Name des Themes ist erforderlich")
|
||||
}
|
||||
if len([]rune(name)) > 60 {
|
||||
return nil, errors.New("Name des Themes ist zu lang (max. 60 Zeichen)")
|
||||
}
|
||||
if strings.TrimSpace(css) == "" {
|
||||
return nil, errors.New("Das Theme enthält kein CSS")
|
||||
}
|
||||
if len(css) > MaxThemeCSSBytes {
|
||||
return nil, errors.New("Das Theme ist zu groß (max. 200 KB)")
|
||||
}
|
||||
now := domain.NowISO()
|
||||
if themeID == "" {
|
||||
t := &domain.UserTheme{ID: domain.NewID("thm"), Name: name, CSS: css, CreatedAt: now, UpdatedAt: now}
|
||||
if _, err := s.db.Exec(
|
||||
`INSERT INTO user_themes (id, user_id, name, css_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
t.ID, userID, t.Name, t.CSS, t.CreatedAt, t.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE user_themes SET name = ?, css_content = ?, updated_at = ? WHERE id = ? AND user_id = ?`,
|
||||
name, css, now, themeID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil, errors.New("Theme nicht gefunden")
|
||||
}
|
||||
t := &domain.UserTheme{ID: themeID, Name: name, CSS: css, UpdatedAt: now}
|
||||
_ = s.db.QueryRow(`SELECT created_at FROM user_themes WHERE id = ?`, themeID).Scan(&t.CreatedAt)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserTheme(userID, themeID string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM user_themes WHERE id = ? AND user_id = ?`, themeID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return errors.New("Theme nicht gefunden")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserThemesArePrivateAndLimited(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "themes.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
|
||||
css := `:root[data-theme="wald"] { --accent-primary: #15803d; }`
|
||||
th, err := store.SaveUserTheme(anna.ID, "", "Wald", css)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveUserTheme failed: %v", err)
|
||||
}
|
||||
if _, err := store.SaveUserTheme(anna.ID, th.ID, "Wald 2", css); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
if themes, _ := store.GetUserThemes(anna.ID); len(themes) != 1 || themes[0].Name != "Wald 2" {
|
||||
t.Fatalf("expected one updated theme, got %+v", themes)
|
||||
}
|
||||
|
||||
// Other users neither see, change nor delete it
|
||||
if themes, _ := store.GetUserThemes(ben.ID); len(themes) != 0 {
|
||||
t.Errorf("Ben must not see Anna's themes")
|
||||
}
|
||||
if _, err := store.SaveUserTheme(ben.ID, th.ID, "Gekapert", css); err == nil {
|
||||
t.Errorf("Ben must not update Anna's theme")
|
||||
}
|
||||
if err := store.DeleteUserTheme(ben.ID, th.ID); err == nil {
|
||||
t.Errorf("Ben must not delete Anna's theme")
|
||||
}
|
||||
|
||||
if _, err := store.SaveUserTheme(anna.ID, "", "Riesig", strings.Repeat("a", MaxThemeCSSBytes+1)); err == nil {
|
||||
t.Errorf("oversized theme must be rejected")
|
||||
}
|
||||
if _, err := store.SaveUserTheme(anna.ID, "", " ", css); err == nil {
|
||||
t.Errorf("theme without name must be rejected")
|
||||
}
|
||||
if err := store.DeleteUserTheme(anna.ID, th.ID); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 344 KiB |