From 08d9b2dc77888a4c8c1baf724b2a21671c553611 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 12:40:22 +0100 Subject: [PATCH] feat(workspace): allow sharing notes and shared folders as editor The share dialog for notes and shared folders now offers "Bearbeiten" and "Nur Lesen" and stores write/read accordingly. Editors already edit through the sync engine since phase 2. Adds e2e/share.e2e.mjs: shares a note as editor through the dialog and checks that the recipient can edit it; e2e/run.sh runs both browser tests. Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 4 +- Doku/plans/kollaboration-notizen.md | 2 +- Doku/sharing-and-permissions.md | 2 +- e2e/README.md | 6 +- e2e/run.sh | 2 +- e2e/share.e2e.mjs | 108 ++++++++++++++++++ .../src/features/workspace/WorkspaceView.tsx | 11 +- 7 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 e2e/share.e2e.mjs diff --git a/Doku/flaws.md b/Doku/flaws.md index 518f8fc..47b44b3 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -26,7 +26,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | -| FLAW-17 | Workspace | Bearbeiter-Freigaben für Notizen erst im Backend aktiv, nicht im UI | Niedrig | +| 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-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 | @@ -138,7 +138,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung. ### FLAW-17: Schreibgeschützte Dokumentfreigaben -- **Stand:** Die Zwangsmigration auf `read` ist entfernt, das Backend speichert und prüft Bearbeiter-Freigaben (`DocRole`, `/docs/{id}/sync`). Offen bis Phase 5 des [Kollaborationsplans](plans/kollaboration-notizen.md): Freigabe-Dialog bietet nur „Nur Lesen“, der alte Editor (`PUT /documents/{id}`) schreibt nur für den Eigentümer. +- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`. - **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer). - **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence. - **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen. diff --git a/Doku/plans/kollaboration-notizen.md b/Doku/plans/kollaboration-notizen.md index b7e9c1b..50422a3 100644 --- a/Doku/plans/kollaboration-notizen.md +++ b/Doku/plans/kollaboration-notizen.md @@ -282,7 +282,7 @@ Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im | 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt. **Erledigt.** Abweichung: Die Editor-Komponenten arbeiten weiter mit der flachen Liste (Ebene + Reihenfolge); die Engine übersetzt jede Änderung per Vergleich in Operationen (`lib/docSync/diff.ts`). Sperren werden bereits gehalten (Fokus, 20 s Nachlauf, neue Bullets), aber noch nicht angezeigt. Ende-zu-Ende-Test mit zwei Browsern: `e2e/`. | Bearbeiten funktioniert überall, auch offline | | 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen. Dazu aus Phase 2: gesperrte Bullets schreibgeschützt anzeigen und Struktur-Aktionen auf ihnen gar nicht erst starten (sonst kann Zusammenführen halb ausgeführt werden, siehe FLAW-25); Fokuswechsel nach Enter ohne Frame-Verzögerung (FLAW-26). | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | | 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt | -| 5 | Freigabe-Dialog mit Bearbeiter-Rolle, Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett | +| 5 | Freigabe-Dialog mit Bearbeiter-Rolle (**vorgezogen und erledigt**, für Notizen und geteilte Ordner), Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett | | 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb | Phase 2 ist bewusst vor den Push-Kanälen: Steht die Bearbeitung über reine HTTP-Abfragen, ist der schwierigste Fall (Proxy blockt alles außer HTTP) von Anfang an abgedeckt. WebSocket und SSE machen es danach nur schneller. diff --git a/Doku/sharing-and-permissions.md b/Doku/sharing-and-permissions.md index 7e5daa8..157f658 100644 --- a/Doku/sharing-and-permissions.md +++ b/Doku/sharing-and-permissions.md @@ -17,7 +17,7 @@ | **Notiz** (`doc_shares`, `doc_team_shares`) | alles, Freigaben, löschen | Inhalt, Struktur, Titel | nur lesen | Benutzer, Gruppen | | **Geteilter Ordner** (`workspace_folder_shares`) | alles | Notizen darin bearbeiten | nur lesen | Benutzer, Gruppen | -- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Das Backend akzeptiert Bearbeiter-Freigaben (`write`). Der Freigabe-Dialog bietet bis zur Umstellung des Editors (Phase 5 im [Plan](plans/kollaboration-notizen.md)) noch nur „Nur Lesen“ an, und der alte Editor schreibt weiterhin nur für den Eigentümer. +- **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. diff --git a/e2e/README.md b/e2e/README.md index 74f8156..26b1d18 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -24,4 +24,8 @@ Zwei Browser (Eigentümer und Bearbeiter) arbeiten in derselben Notiz. Geprüft - Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab - Einrücken (Tab) und Zusammenführen (Backspace) bleiben nach Neuladen erhalten -Der Test dauert wegen des Sperrablaufs etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht. +## share.e2e.mjs + +Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. + +`collab.e2e.mjs` dauert wegen des Sperrablaufs etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht. diff --git a/e2e/run.sh b/e2e/run.sh index 2ac77e9..d047786 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -15,4 +15,4 @@ for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev echo "Starte Browser-Tests …" "$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ - sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs' + sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs' diff --git a/e2e/share.e2e.mjs b/e2e/share.e2e.mjs new file mode 100644 index 0000000..3ff1337 --- /dev/null +++ b/e2e/share.e2e.mjs @@ -0,0 +1,108 @@ +// Shares a note as editor through the share dialog and checks that the editor can then edit it. +// Needs a server in demo mode (accounts admin/admin and user/user); see e2e/README.md. +import { chromium } from 'playwright'; + +const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failures = 0; +const check = (cond, msg) => { + console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`); + if (!cond) failures++; +}; + +async function api(token, method, path, body) { + const res = await fetch(BASE + path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`); + return text ? JSON.parse(text) : null; +} + +async function waitFor(fn, ms) { + const end = Date.now() + ms; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch {} + await sleep(250); + } + return null; +} + +async function openDoc(browser, token, title) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token); + const page = await ctx.newPage(); + await page.goto(BASE); + await page.getByRole('button', { name: /Workspace/ }).first().click(); + await page.getByText(title, { exact: true }).first().click(); + await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 }); + return { ctx, page }; +} + +const TITLE = `Freigabe-Test ${Date.now()}`; + +const main = async () => { + const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token; + const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token; + const doc = await api(admin, 'POST', '/api/v1/documents', { + title: TITLE, + blocks: [{ id: `fr_${Date.now()}`, type: 'bullet', content: 'Vom Eigentümer', level: 0 }], + }); + + const browser = await chromium.launch(); + const A = await openDoc(browser, admin, TITLE); + + // Share as editor through the dialog + await A.page.getByTitle('Notiz teilen und Berechtigungen').click(); + await A.page.getByRole('button', { name: 'Benutzer', exact: true }).click(); + const search = A.page.getByPlaceholder(/Benutzer suchen/); + await search.click(); + await search.type('user'); + await sleep(300); + await search.press('ArrowDown'); + await search.press('Enter'); + 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'); + await A.page.getByRole('button', { name: /Benutzer hinzufügen/ }).click(); + const shares = await waitFor(async () => { + const list = await api(admin, 'GET', `/api/v1/documents/${doc.id}/shares`); + return list.length > 0 ? list : null; + }, 5000); + check(shares && shares[0].permission === 'write', `Freigabe gespeichert als ${shares?.[0]?.permission}`); + const badge = await A.page + .getByText('Bearbeiter', { exact: true }) + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + check(badge, 'Dialog zeigt die Rolle „Bearbeiter“'); + + // The editor can edit + const snap = await api(user, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + check(snap.role === 'editor', `Empfänger hat die Rolle ${snap.role}`); + const B = await openDoc(browser, user, TITLE); + check(!(await B.page.getByText('schreibgeschützt mit dir geteilt', { exact: false }).first().isVisible().catch(() => false)), 'Kein Nur-Lesen-Hinweis beim Bearbeiter'); + await B.page.getByText('Vom Eigentümer', { exact: true }).first().click(); + await B.page.keyboard.press('End'); + await B.page.keyboard.type(' und vom Bearbeiter'); + const saved = await waitFor(async () => { + const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + return s.blocks.some((b) => b.content === 'Vom Eigentümer und vom Bearbeiter'); + }, 6000); + check(!!saved, 'Bearbeiter kann die geteilte Notiz bearbeiten'); + + await browser.close(); + console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN'); + process.exit(failures ? 1 : 0); +}; + +main().catch((e) => { + console.error(e); + process.exit(2); +}); diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 60b8650..5759d12 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -2268,15 +2268,12 @@ export const WorkspaceView: React.FC = ({ shares={unifiedShares} teams={teams} collaborators={collaborators} - allowRoleSelection={false} - fixedPermission="viewer" - onAddShare={async (targetType, targetId) => { + onAddShare={async (targetType, targetId, role) => { + const permission = role === 'editor' ? 'write' : 'read'; if (shareModalDoc) { await api.createDocShare( shareModalDoc.id, - targetType === 'team' - ? { teamId: targetId, permission: 'read' } - : { userId: targetId, permission: 'read' } + targetType === 'team' ? { teamId: targetId, permission } : { userId: targetId, permission } ); const updatedShares = await api.getDocShares(shareModalDoc.id); setActiveDocShares(updatedShares); @@ -2284,7 +2281,7 @@ export const WorkspaceView: React.FC = ({ await api.createFolderShare(shareModalFolder.id, { targetType, targetId, - permission: 'read', + permission, }); const updatedFolder = await api.getSharedFolder(shareModalFolder.id); setShareModalFolder(updatedFolder);