feat(workspace): allow sharing notes and shared folders as editor
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m13s

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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 12:40:22 +01:00
1 parent 51f90eb7e4
commit 08d9b2dc77
7 files changed
+122 -13

No files matched your search

+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+5 -1
View File
@@ -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.
+1 -1
View File
@@ -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'
+108
View File
@@ -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);
});
@@ -2268,15 +2268,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
await api.createFolderShare(shareModalFolder.id, {
targetType,
targetId,
permission: 'read',
permission,
});
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);