From bf893afc6150740a6b8ed7af17adca8222883af7 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 10:52:35 +0200 Subject: [PATCH] feat(quick-261009-dkv): Teilen mit Personen und Gruppen - Durchstich, Oberflaeche - Teilen-Dialog (Suche mit Verzoegerung, Ergebnisliste als Combobox, Berechtigung je Zeile aendern, entfernen) auf dem Modul-Dialog, Fehler mit Meldung der Nextcloud - Menueeintrag Teilen (nur bei Buchstabe R), Freigabe-Kennzeichen in Liste und Raster (ausgehend als Knopf, eingehend als Markierung) - Web-Client, reine Hilfen (Berechtigungsauswahl, Passwort erzeugen), Fehlertexte und Meldungen de/en; sharingDisabled traegt scope=groups fuer den Gruppentext Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nextcloud-files-shares.service.spec.ts | 1 + .../nextcloud-files-shares.service.ts | 2 +- .../components/FileBrowser.test.tsx | 127 +++++ .../components/FileBrowser.tsx | 29 +- .../nextcloud-files/components/FileGrid.tsx | 20 +- .../nextcloud-files/components/FileList.tsx | 8 + .../components/ShareDialog.test.tsx | 378 +++++++++++++++ .../components/ShareDialog.tsx | 455 ++++++++++++++++++ .../components/ShareIndicator.tsx | 72 +++ .../nextcloud-files/components/icons.tsx | 37 ++ .../nextcloud-files-page.test.tsx | 1 + .../components/nextcloud-files/error-text.ts | 30 +- .../nextcloud-files/share-policy.test.ts | 105 ++++ .../nextcloud-files/share-policy.ts | 100 ++++ apps/web/src/lib/nextcloud-files-api.test.ts | 77 +++ apps/web/src/lib/nextcloud-files-api.ts | 110 +++++ apps/web/src/messages/de.json | 49 +- apps/web/src/messages/en.json | 49 +- apps/web/src/messages/umlaut-dictionary.ts | 2 + 19 files changed, 1640 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx create mode 100644 apps/web/src/components/nextcloud-files/share-policy.test.ts create mode 100644 apps/web/src/components/nextcloud-files/share-policy.ts diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts index 415229e..9adf125 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts @@ -222,6 +222,7 @@ describe('NextcloudFilesSharesService — create', () => { expect(codeOf(e)).toBe('sharingDisabled'); expect(statusOf(e)).toBe(409); expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.'); + expect(bodyOf(e).scope).toBe('groups'); expect(calls.some((c) => c.method === 'POST')).toBe(false); }); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts index a3feb55..4248926 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts @@ -290,7 +290,7 @@ export class NextcloudFilesSharesService { const policy = await this.loadPolicy(tenantId, userId, session); if (!policy.enabled) throw ncErrorDefault('sharingDisabled'); if (input.kind === 'group' && !policy.groupsEnabled) { - throw ncErrorDefault('sharingDisabled', undefined, GROUPS_OFF); + throw ncErrorDefault('sharingDisabled', { scope: 'groups' }, GROUPS_OFF); } if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected'); if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) { diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx index 70d13c5..3f5339f 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx @@ -27,6 +27,8 @@ const mockDelete = vi.fn(); const mockUpload = vi.fn(); const mockCheckDownload = vi.fn(); const mockCheckZip = vi.fn(); +const mockPolicy = vi.fn(); +const mockSharesForPath = vi.fn(); vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { const actual = await importOriginal(); @@ -38,6 +40,8 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { deleteEntry: (...a: unknown[]) => mockDelete(...a), checkDownload: (...a: unknown[]) => mockCheckDownload(...a), checkZip: (...a: unknown[]) => mockCheckZip(...a), + getSharePolicy: (...a: unknown[]) => mockPolicy(...a), + listSharesForPath: (...a: unknown[]) => mockSharesForPath(...a), }; }); vi.mock('@/lib/nextcloud-files-upload', () => ({ @@ -61,6 +65,7 @@ function entry(path: string, over: Partial = {}): NcEntry { permissions: 'RGDNVW', hasPreview: false, favorite: false, + shareTypes: [], ...over, }; } @@ -117,6 +122,30 @@ beforeEach(() => { mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 }); mockCheckDownload.mockReset().mockResolvedValue({ ok: true }); mockCheckZip.mockReset().mockResolvedValue({ ok: true }); + mockPolicy.mockReset().mockResolvedValue({ + enabled: true, + groupsEnabled: true, + links: { + enabled: true, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: true, + multipleLinks: true, + }, + internalExpiry: { defaultDays: null, enforced: false }, + minSearchLength: 0, + passwordMinLength: 10, + }); + mockSharesForPath + .mockReset() + .mockImplementation(async (p: string) => ({ + path: p, + shares: [], + hidden: 0, + truncated: false, + })); onExpired = vi.fn(); clicked = []; submitted = []; @@ -379,6 +408,7 @@ describe('FileBrowser — Liste', () => { 'Herunterladen', 'Umbenennen', 'Verschieben', + 'Teilen', 'In Nextcloud öffnen', 'Löschen', ]); @@ -634,3 +664,100 @@ describe('FileBrowser — Vorschaubilder (WR-03)', () => { expect(mockList.mock.calls.length - callsBefore).toBe(1); }); }); + +describe('FileBrowser — Teilen (quick-261009-dkv)', () => { + it('das Menue bietet Teilen nur bei Eintraegen mit dem Buchstaben R und oeffnet den Dialog', async () => { + mockList.mockImplementation(async (p: string) => + listing( + p, + p === '/' + ? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'GVW' })] + : (FOLDERS[p] ?? []), + ), + ); + await mount(); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Fremd.txt' })); + expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull(); + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + + fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' })); + const dialog = await screen.findByRole('dialog', { name: '„Projekte“ teilen' }); + expect(within(dialog).getByRole('heading', { name: '„Projekte“ teilen' })).toBeTruthy(); + await waitFor(() => expect(mockSharesForPath).toHaveBeenCalledWith('/Projekte')); + }); + + it('ist Teilen ausgeschaltet (sharingEnabled false), fehlen Menueeintrag und Knopf am Kennzeichen', async () => { + mockList.mockImplementation(async (p: string) => + listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [0] })] : []), + ); + render(); + await screen.findByRole('row', { name: 'Projekte' }); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); + expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Freigaben von „Projekte“ öffnen' })).toBeNull(); + }); + + it('ein geteilter Eintrag traegt den Knopf am Kennzeichen, der den Dialog oeffnet', async () => { + mockList.mockImplementation(async (p: string) => + listing( + p, + p === '/' + ? [folder('/Projekte', { shareTypes: [0] }), entry('/Bericht.pdf', { shareTypes: [3] })] + : [], + ), + ); + await mount(); + const row = screen.getByRole('row', { name: 'Projekte' }); + fireEvent.click(within(row).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' })); + expect(await screen.findByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy(); + // Das Kennzeichen waehlt die Zeile nicht aus. + expect(row.getAttribute('aria-selected')).toBe('false'); + // Der zweite Eintrag hat ebenfalls ein Kennzeichen + expect( + within(screen.getByRole('row', { name: 'Bericht.pdf' })).getByRole('button', { + name: 'Freigaben von „Bericht.pdf“ öffnen', + }), + ).toBeTruthy(); + }); + + it('ein Eintrag mit S in den Berechtigungen zeigt die Markierung "Mit Ihnen geteilt"', async () => { + mockList.mockImplementation(async (p: string) => + listing( + p, + p === '/' ? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'SGVW' })] : [], + ), + ); + await mount(); + const row = screen.getByRole('row', { name: 'Fremd.txt' }); + expect(within(row).getByTestId('share-incoming').getAttribute('title')).toBe( + 'Mit Ihnen geteilt', + ); + expect(within(row).getByText('Mit Ihnen geteilt')).toBeTruthy(); + expect( + within(screen.getByRole('row', { name: 'Projekte' })).queryByTestId('share-incoming'), + ).toBeNull(); + }); + + it('im Raster erscheint das Kennzeichen ebenfalls', async () => { + mockList.mockImplementation(async (p: string) => + listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [1] })] : []), + ); + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Raster' })); + const grid = await screen.findByTestId('nc-files-grid'); + expect( + within(grid).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' }), + ).toBeTruthy(); + }); + + it('ein abgelaufener Zugang im Dialog fuehrt zurueck zum Anmeldebildschirm', async () => { + mockPolicy.mockRejectedValue( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + await mount(); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' })); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx index 4932460..9992411 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx @@ -6,6 +6,7 @@ import { collectDrop, hasFiles, snapshotDrop } from '@/components/nextcloud-file import { type ErrorLike, errorText, toErrorLike } from '@/components/nextcloud-files/error-text'; import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-files/paths'; import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection'; +import { type ShareTarget, targetFromEntry } from '@/components/nextcloud-files/share-policy'; import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers'; import { checkDownload, @@ -35,6 +36,7 @@ import { OpenIcon, PencilIcon, RetryIcon, + ShareIcon, TrashIcon, UploadIcon, } from './icons'; @@ -42,6 +44,7 @@ import { MoveDialog } from './MoveDialog'; import { NameDialog } from './NameDialog'; import { QuotaMeter } from './QuotaMeter'; import { SelectionBar } from './SelectionBar'; +import { ShareDialog } from './ShareDialog'; import { type SortDir, type SortKey, Toolbar, type ViewMode } from './Toolbar'; import { TransferBar } from './TransferBar'; @@ -52,7 +55,8 @@ type DialogState = | { kind: 'newFolder' } | { kind: 'rename'; entry: NcEntry } | { kind: 'move'; entries: NcEntry[] } - | { kind: 'delete'; entries: NcEntry[] }; + | { kind: 'delete'; entries: NcEntry[] } + | { kind: 'share'; target: ShareTarget }; interface MenuState { entry: NcEntry; @@ -143,11 +147,14 @@ function isTypingTarget(el: EventTarget | null): boolean { export function FileBrowser({ serverUrl, onExpired, + sharingEnabled = true, }: { /** Adresse der Nextcloud fuer "In Nextcloud öffnen". */ serverUrl: string | null; /** Die Verbindung ist abgelaufen: die Seite zeigt wieder den Anmeldebildschirm. */ onExpired: () => void; + /** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */ + sharingEnabled?: boolean; }) { const t = useTranslations('nextcloudFiles.browser'); const tCodes = useTranslations('nextcloudFiles.codes'); @@ -672,6 +679,15 @@ export function FileBrowser({ onSelect: () => setDialog({ kind: 'move', entries: [entry] }), }, ); + // Nur Eintraege, die Nextcloud als teilbar kennzeichnet (Buchstabe R). + if (sharingEnabled && entry.permissions.includes('R')) { + actions.push({ + id: 'share', + label: t('menu.share'), + icon: , + onSelect: () => setDialog({ kind: 'share', target: targetFromEntry(entry) }), + }); + } if (serverUrl && entry.fileId) { actions.push({ id: 'openInNextcloud', @@ -856,6 +872,9 @@ export function FileBrowser({ onToggle, onMenu: openMenu, onFocusItem: setFocusIndex, + onShare: sharingEnabled + ? (entry: NcEntry) => setDialog({ kind: 'share', target: targetFromEntry(entry) }) + : undefined, onPreviewError, }; @@ -1072,6 +1091,14 @@ export function FileBrowser({ onClose={() => setDialog(null)} /> )} + {dialog?.kind === 'share' && ( + void load(pathRef.current, { quiet: true })} + onExpired={() => onExpiredRef.current()} + onClose={() => setDialog(null)} + /> + )} ); } diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx index dbb0199..3fe739e 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx @@ -7,6 +7,7 @@ import { fileTypeOf } from '@/components/nextcloud-files/file-types'; import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api'; import type { EntryViewProps } from './FileList'; import { CheckIcon, MoreIcon } from './icons'; +import { ShareIndicator } from './ShareIndicator'; import { TypeTile } from './TypeTile'; /** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */ @@ -54,6 +55,7 @@ export function FileGrid({ onToggle, onMenu, onFocusItem, + onShare, onPreviewError, }: EntryViewProps) { const t = useTranslations('nextcloudFiles.browser'); @@ -169,12 +171,18 @@ export function FileGrid({ {entry.name}

)} -

- {formatSize(entry.size, locale)} -

+
+

+ {formatSize(entry.size, locale)} +

+ onShare(entry) : undefined} + /> +
); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx index 2a874e4..8dff4ea 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx @@ -10,6 +10,7 @@ import { import { downloadUrl, type NcEntry } from '@/lib/nextcloud-files-api'; import { ChevronDownIcon, ChevronUpIcon, MoreIcon } from './icons'; import type { SortDir, SortKey } from './Toolbar'; +import { ShareIndicator } from './ShareIndicator'; import { TypeTile } from './TypeTile'; /** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */ @@ -29,6 +30,8 @@ export interface EntryViewProps { onToggle: (entry: NcEntry, index: number) => void; onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void; onFocusItem: (index: number) => void; + /** Teilen-Dialog eines Eintrags oeffnen (Kennzeichen); fehlt sie, ist Teilen ausgeschaltet. */ + onShare?: (entry: NcEntry) => void; /** * Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die * Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck. @@ -55,6 +58,7 @@ export function FileList({ onToggle, onMenu, onFocusItem, + onShare, onPreviewError, sort, onSortChange, @@ -221,6 +225,10 @@ export function FileList({ {entry.name} )} + onShare(entry) : undefined} + /> diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx new file mode 100644 index 0000000..bc50512 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx @@ -0,0 +1,378 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ShareTarget } from '@/components/nextcloud-files/share-policy'; +import { + type NcShare, + type NcSharee, + type NcSharePolicy, + NextcloudFilesRequestError, +} from '@/lib/nextcloud-files-api'; +import de from '@/messages/de.json'; +import { ShareDialog } from './ShareDialog'; + +const mockPolicy = vi.fn(); +const mockList = vi.fn(); +const mockSearch = vi.fn(); +const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSharePolicy: (...a: unknown[]) => mockPolicy(...a), + listSharesForPath: (...a: unknown[]) => mockList(...a), + searchSharees: (...a: unknown[]) => mockSearch(...a), + createShare: (...a: unknown[]) => mockCreate(...a), + updateShare: (...a: unknown[]) => mockUpdate(...a), + deleteShare: (...a: unknown[]) => mockDelete(...a), + }; +}); + +const POLICY: NcSharePolicy = { + enabled: true, + groupsEnabled: true, + links: { + enabled: true, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: true, + multipleLinks: true, + }, + internalExpiry: { defaultDays: null, enforced: false }, + minSearchLength: 0, + passwordMinLength: 10, +}; + +const FOLDER: ShareTarget = { + path: '/Projekte', + name: 'Projekte', + type: 'folder', + mime: null, + writable: true, +}; + +function share(over: Partial = {}): NcShare { + return { + id: '17', + kind: 'user', + path: '/Projekte', + name: 'Projekte', + itemType: 'folder', + mime: null, + itemWritable: true, + permissions: 1, + access: 'view', + shareWith: 'ben', + shareWithName: 'Ben Beispiel', + ownerId: 'anna', + ownerName: 'Anna Müller', + canEdit: true, + canDelete: true, + expiration: null, + label: '', + url: null, + hasPassword: false, + target: '/Projekte', + sharedAt: null, + ...over, + }; +} + +const BEN: NcSharee = { kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' }; +const ZOE: NcSharee = { kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' }; +const TEAM: NcSharee = { kind: 'group', id: 'tessera-team', label: 'tessera-team', detail: null }; + +let onClose: ReturnType; +let onChanged: ReturnType; +let onExpired: ReturnType; + +function mount(target: ShareTarget = FOLDER) { + return render( + + + , + ); +} + +async function ready() { + return screen.findByRole('combobox', { name: 'Person oder Gruppe suchen' }); +} + +function type(input: HTMLElement, value: string) { + fireEvent.change(input, { target: { value } }); +} + +beforeEach(() => { + onClose = vi.fn(); + onChanged = vi.fn(); + onExpired = vi.fn(); + mockPolicy.mockReset().mockResolvedValue(POLICY); + mockList + .mockReset() + .mockResolvedValue({ path: '/Projekte', shares: [], hidden: 0, truncated: false }); + mockSearch.mockReset().mockResolvedValue({ sharees: [BEN, ZOE, TEAM] }); + mockCreate + .mockReset() + .mockImplementation(async (input: { shareWith: string; kind: string }) => + share({ id: '99', shareWith: input.shareWith, kind: input.kind as 'user' | 'group' }), + ); + mockUpdate + .mockReset() + .mockImplementation(async (_id: string, input: { access: 'view' | 'edit' }) => + share({ access: input.access, permissions: input.access === 'edit' ? 15 : 1 }), + ); + mockDelete.mockReset().mockResolvedValue({ deleted: true }); +}); + +afterEach(() => cleanup()); + +describe('ShareDialog — Personen und Gruppen', () => { + it('zeigt die vorhandenen Freigaben mit Gruppenkennzeichen und Ablaufdatum', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [ + share(), + share({ + id: '18', + kind: 'group', + shareWith: 'tessera-team', + shareWithName: 'tessera-team', + expiration: '2026-12-31', + }), + ], + hidden: 2, + truncated: false, + }); + mount(); + await ready(); + expect(screen.getByText('Ben Beispiel')).toBeTruthy(); + const teamRow = screen.getByText('tessera-team').closest('li') as HTMLElement; + expect(within(teamRow).getByText('Gruppe')).toBeTruthy(); + expect(within(teamRow).getByText('gültig bis 31.12.2026')).toBeTruthy(); + expect( + screen.getByText('2 weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.'), + ).toBeTruthy(); + expect(mockList).toHaveBeenCalledWith('/Projekte'); + }); + + it('tippen ruft die Suche einmal nach der Verzoegerung auf; Auswaehlen legt genau eine Freigabe an', async () => { + mount(); + const input = await ready(); + type(input, 'b'); + type(input, 'be'); + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)); + expect(mockSearch).toHaveBeenCalledWith('be', 'folder'); + const list = await screen.findByRole('listbox', { name: 'Suchergebnisse' }); + expect(within(list).getAllByRole('option')).toHaveLength(3); + fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate).toHaveBeenCalledWith({ + path: '/Projekte', + kind: 'user', + shareWith: 'ben', + access: 'view', + }); + expect( + await screen.findByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }), + ).toBeTruthy(); + expect(onChanged).toHaveBeenCalledTimes(1); + expect((input as HTMLInputElement).value).toBe(''); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('die gewaehlte Berechtigung gilt fuer die naechste Freigabe; eine Gruppe wird als Gruppe angelegt', async () => { + mount(); + const input = await ready(); + fireEvent.change( + screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' }), + { + target: { value: 'edit' }, + }, + ); + type(input, 'tes'); + const list = await screen.findByRole('listbox'); + fireEvent.click(within(list).getByRole('option', { name: /tessera-team/ })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate).toHaveBeenCalledWith({ + path: '/Projekte', + kind: 'group', + shareWith: 'tessera-team', + access: 'edit', + }); + }); + + it('ein bereits geteilter Empfaenger ist gesperrt und legt nichts an', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [share()], + hidden: 0, + truncated: false, + }); + mount(); + const input = await ready(); + type(input, 'be'); + const list = await screen.findByRole('listbox'); + const option = within(list).getByRole('option', { name: /Ben Beispiel/ }); + expect(option.getAttribute('aria-disabled')).toBe('true'); + expect(within(option).getByText('bereits geteilt')).toBeTruthy(); + fireEvent.click(option); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('Pfeiltasten und Enter waehlen; Escape schliesst zuerst nur die Ergebnisliste', async () => { + mount(); + const input = await ready(); + type(input, 'be'); + await screen.findByRole('listbox'); + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(input.getAttribute('aria-expanded')).toBe('true'); + const active = input.getAttribute('aria-activedescendant'); + expect(active).toBeTruthy(); + expect(document.getElementById(active as string)?.textContent).toContain('Zwei Faktor'); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0]).toMatchObject({ shareWith: 'zoe' }); + }); + + it('ist die Gruppenfreigabe ausgeschaltet, erscheinen keine Gruppen in der Suche', async () => { + mockPolicy.mockResolvedValue({ ...POLICY, groupsEnabled: false }); + mount(); + const input = await ready(); + type(input, 'te'); + const list = await screen.findByRole('listbox'); + expect(within(list).queryByRole('option', { name: /tessera-team/ })).toBeNull(); + expect(within(list).getAllByRole('option')).toHaveLength(2); + }); + + it('ein Eintrag ohne Schreibrecht bietet nur "Ansehen"', async () => { + mount({ ...FOLDER, writable: false }); + await ready(); + const select = screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' }); + expect( + within(select) + .getAllByRole('option') + .map((o) => o.textContent), + ).toEqual(['Ansehen']); + }); + + it('Berechtigung einer Zeile aendern ruft updateShare mit der Auswahl, nie mit einer Maske', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [share()], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + fireEvent.change(screen.getByRole('combobox', { name: 'Berechtigung für Ben Beispiel' }), { + target: { value: 'edit' }, + }); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(mockUpdate).toHaveBeenCalledWith('17', { access: 'edit' }); + await waitFor(() => + expect( + ( + screen.getByRole('combobox', { + name: 'Berechtigung für Ben Beispiel', + }) as HTMLSelectElement + ).value, + ).toBe('edit'), + ); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it('Entfernen ruft deleteShare und nimmt die Zeile heraus', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [share()], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' })); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('17')); + await waitFor(() => expect(screen.queryByText('Ben Beispiel')).toBeNull()); + expect(screen.getByText('Noch mit niemandem geteilt.')).toBeTruthy(); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it('shareRejected zeigt den deutschen Text und die Meldung der Nextcloud als zweite Zeile', async () => { + mockCreate.mockRejectedValue( + new NextcloudFilesRequestError( + 422, + 'shareRejected', + 'Nextcloud hat diese Freigabe abgelehnt.', + { + ncMessage: 'You cannot share a folder that contains other shares', + }, + ), + ); + mount(); + const input = await ready(); + type(input, 'be'); + const list = await screen.findByRole('listbox'); + fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Nextcloud hat diese Freigabe abgelehnt.'); + expect(alert.textContent).toContain( + 'Meldung der Nextcloud: You cannot share a folder that contains other shares', + ); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('tooManyShares nennt die Wartezeit in Minuten', async () => { + mockCreate.mockRejectedValue( + new NextcloudFilesRequestError(429, 'tooManyShares', 'x', { retryAfterSeconds: 585 }), + ); + mount(); + const input = await ready(); + type(input, 'be'); + fireEvent.click( + within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }), + ); + expect((await screen.findByRole('alert')).textContent).toContain( + 'Bitte warten Sie 10 Minuten.', + ); + }); + + it('connectionExpired ruft onExpired', async () => { + mockCreate.mockRejectedValue( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + mount(); + const input = await ready(); + type(input, 'be'); + fireEvent.click( + within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }), + ); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + }); + + it('Teilen in der Nextcloud ausgeschaltet: nur der Hinweis, kein Suchfeld', async () => { + mockPolicy.mockResolvedValue({ ...POLICY, enabled: false }); + mount(); + expect(await screen.findByText('Teilen ist in Ihrer Nextcloud ausgeschaltet.')).toBeTruthy(); + expect(screen.queryByRole('combobox')).toBeNull(); + }); + + it('Fertig schliesst den Dialog; der Titel nennt den Namen', async () => { + mount(); + await ready(); + expect(screen.getByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Fertig' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx new file mode 100644 index 0000000..41cf05f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { + type ErrorLike, + errorText, + ncMessageOf, + toErrorLike, +} from '@/components/nextcloud-files/error-text'; +import { + accessOptions, + type ShareAccessChoice, + type ShareTarget, +} from '@/components/nextcloud-files/share-policy'; +import { + createShare, + deleteShare, + getSharePolicy, + listSharesForPath, + type NcShare, + type NcSharee, + type NcSharePolicy, + searchSharees, + updateShare, +} from '@/lib/nextcloud-files-api'; +import { Dialog } from './Dialog'; +import { CloseIcon, UserIcon, UsersIcon } from './icons'; + +const SEARCH_DEBOUNCE_MS = 300; +const SELECT_CLASS = + 'rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60'; + +/** Datum `YYYY-MM-DD` in der Landessprache (Mittag, damit keine Zeitzone den Tag verschiebt). */ +function dateText(date: string, locale: string): string { + const d = new Date(`${date}T12:00:00`); + return Number.isNaN(d.getTime()) ? date : d.toLocaleDateString(locale); +} + +/** + * Teilen-Dialog (quick-261009-dkv, D-19): Personen und Gruppen suchen und hinzufuegen, + * Berechtigung aendern, Freigabe entfernen. Gebaut auf `Dialog`, damit die Tastenbelegung der + * Dateiansicht (Entf, F2 ...) beim Tippen im Suchfeld nicht greift. Alle Regeln (was angeboten + * wird, ob Gruppen gehen) kommen frisch von der Nextcloud ueber die API; Fehler erscheinen mit + * dem eigenen Text und, falls vorhanden, der Meldung der Nextcloud als zweiter Zeile. + */ +export function ShareDialog({ + target, + onClose, + onChanged, + onExpired, +}: { + target: ShareTarget; + onClose: () => void; + /** Nach jeder gelungenen Aenderung (die Dateiliste laedt neu). */ + onChanged: () => void; + /** Die Verbindung ist abgelaufen. */ + onExpired: () => void; +}) { + const t = useTranslations('nextcloudFiles.share'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const locale = useLocale(); + const ids = useId(); + const inputId = `${ids}-search`; + const listId = `${ids}-list`; + const optionId = (i: number) => `${ids}-opt-${i}`; + + const [policy, setPolicy] = useState(null); + const [shares, setShares] = useState([]); + const [hidden, setHidden] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const [term, setTerm] = useState(''); + const [results, setResults] = useState([]); + const [searchedFor, setSearchedFor] = useState(null); + const [searching, setSearching] = useState(false); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(-1); + const [newAccess, setNewAccess] = useState('view'); + + const input = useRef(null); + const searchSeq = useRef(0); + const onExpiredRef = useRef(onExpired); + onExpiredRef.current = onExpired; + + const fail = (err: unknown) => { + const e = toErrorLike(err); + if (e.code === 'connectionExpired') { + onExpiredRef.current(); + return; + } + setError(e); + }; + const failRef = useRef(fail); + failRef.current = fail; + + // Regeln und vorhandene Freigaben laden. + useEffect(() => { + let cancelled = false; + setLoading(true); + Promise.all([getSharePolicy(), listSharesForPath(target.path)]) + .then(([p, list]) => { + if (cancelled) return; + setPolicy(p); + setShares(list.shares); + setHidden(list.hidden); + }) + .catch((err) => { + if (!cancelled) failRef.current(err); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [target.path]); + + const enabled = policy?.enabled === true; + const minChars = Math.max(1, policy?.minSearchLength ?? 1); + + // Sobald der Dialog bereit ist, steht der Cursor im Suchfeld. + useEffect(() => { + if (!loading && enabled) input.current?.focus(); + }, [loading, enabled]); + + // Suche mit Verzoegerung; veraltete Antworten werden verworfen. + useEffect(() => { + const seq = ++searchSeq.current; + const q = term.trim(); + if (!policy?.enabled || q.length < minChars) { + setResults([]); + setSearching(false); + setOpen(false); + setSearchedFor(null); + return; + } + setSearching(true); + const timer = window.setTimeout(() => { + searchSharees(q, target.type) + .then((out) => { + if (seq !== searchSeq.current) return; + setResults(out.sharees.filter((s) => s.kind === 'user' || policy.groupsEnabled)); + setSearchedFor(q); + setActive(-1); + setOpen(true); + }) + .catch((err) => { + if (seq !== searchSeq.current) return; + setResults([]); + failRef.current(err); + }) + .finally(() => { + if (seq === searchSeq.current) setSearching(false); + }); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [term, policy, target.type, minChars]); + + const sharedKeys = useMemo( + () => new Set(shares.filter((s) => s.kind !== 'link').map((s) => `${s.kind}:${s.shareWith}`)), + [shares], + ); + const people = shares.filter((s) => s.kind !== 'link'); + const options = policy + ? accessOptions(target, 'user', policy) + : (['view'] as ShareAccessChoice[]); + + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await action(); + } catch (err) { + fail(err); + } finally { + setBusy(false); + } + }; + + const choose = (s: NcSharee) => { + if (sharedKeys.has(`${s.kind}:${s.id}`)) return; + void run(async () => { + const created = await createShare({ + path: target.path, + kind: s.kind, + shareWith: s.id, + access: newAccess, + }); + setShares((prev) => [...prev, created]); + setTerm(''); + setResults([]); + setOpen(false); + onChanged(); + input.current?.focus(); + }); + }; + + const changeAccess = (share: NcShare, access: ShareAccessChoice) => + run(async () => { + const updated = await updateShare(share.id, { access }); + setShares((prev) => prev.map((s) => (s.id === share.id ? updated : s))); + onChanged(); + }); + + const remove = (share: NcShare) => + run(async () => { + await deleteShare(share.id); + setShares((prev) => prev.filter((s) => s.id !== share.id)); + onChanged(); + }); + + const onInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (results.length === 0) return; + setOpen(true); + setActive((a) => Math.min(results.length - 1, a + 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((a) => Math.max(0, a - 1)); + } else if (e.key === 'Enter') { + if (open && active >= 0 && results[active]) { + e.preventDefault(); + choose(results[active]); + } + } else if (e.key === 'Escape' && open) { + // Erst die Ergebnisliste schliessen, nicht gleich den ganzen Dialog. + e.stopPropagation(); + setOpen(false); + } + }; + + const nameOf = (s: NcShare) => s.shareWithName ?? s.shareWith ?? ''; + const trimmed = term.trim(); + const tooShort = trimmed !== '' && trimmed.length < minChars; + const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0; + const detail = error ? ncMessageOf(error) : null; + + return ( + + {t('done')} + + } + > + {loading ? ( +

+ {t('loading')} +

+ ) : !enabled ? ( +

+ {error ? errorText(tCodes, error, locale) : tCodes('sharingDisabled')} +

+ ) : ( +
+
+

+ {t('people.title')} +

+ + +
+ = 0 ? optionId(active) : undefined} + value={term} + autoComplete="off" + spellCheck={false} + disabled={busy} + placeholder={t('people.searchPlaceholder')} + onChange={(e) => { + setTerm(e.target.value); + setError(null); + }} + onKeyDown={onInputKeyDown} + className="min-w-0 flex-1 rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
+ +

+ {searching + ? t('people.searching') + : tooShort + ? t('people.minChars', { count: minChars }) + : noResults + ? t('people.noResults') + : ''} +

+ + {open && results.length > 0 && ( +
    + {results.map((s, i) => { + const already = sharedKeys.has(`${s.kind}:${s.id}`); + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Tastatur ueber das Suchfeld (Pfeile, Enter) +
  • e.preventDefault()} + onClick={() => choose(s)} + className={`flex items-center gap-3 px-3 py-2 text-sm ${ + already + ? 'cursor-not-allowed text-muted-foreground' + : 'cursor-pointer hover:bg-accent/70' + } ${active === i ? 'bg-accent' : ''}`} + > + {s.kind === 'group' ? : } + {s.label} + {s.kind === 'group' && ( + {t('people.group')} + )} + {s.kind === 'user' && s.detail && s.detail !== s.label && ( + {s.detail} + )} + {already && ( + + {t('people.alreadyShared')} + + )} +
  • + ); + })} +
+ )} + + {people.length === 0 ? ( +

{t('people.empty')}

+ ) : ( +
    + {people.map((share) => { + const rowOptions = options.includes(share.access as ShareAccessChoice) + ? options + : [share.access, ...options]; + return ( +
  • + +
    +

    + {nameOf(share)} + {share.kind === 'group' && ( + + {t('people.group')} + + )} +

    + {share.expiration && ( +

    + {t('people.expires', { date: dateText(share.expiration, locale) })} +

    + )} +
    + + +
  • + ); + })} +
+ )} + + {hidden > 0 && ( +

{t('hidden', { count: hidden })}

+ )} +
+
+ )} + + {error && enabled && ( +
+

{errorText(tCodes, error, locale)}

+ {detail && ( +

{tCodes('ncDetail', { detail })}

+ )} +
+ )} + {error && !enabled && !loading && detail && ( +

{tCodes('ncDetail', { detail })}

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx new file mode 100644 index 0000000..3f38288 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareIndicator.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import type { NcEntry } from '@/lib/nextcloud-files-api'; +import { LinkIcon, UsersIcon } from './icons'; + +/** + * Freigabe-Kennzeichen am Namen eines Eintrags (quick-261009-dkv, D-19): + * - ausgehend (`oc:share-types` nicht leer): Knopf mit Link-Symbol, wenn ein Link besteht, + * sonst mit Personen-Symbol; er oeffnet den Teilen-Dialog. + * - eingehend (Buchstabe S in den Berechtigungen): feststehendes Symbol mit Hinweistext, + * ohne Knopf, weil fremde Freigaben hier nicht verwaltet werden. + * Die Kennzeichen sind ruhige Symbole in gedaempfter Farbe, keine Etiketten. + */ +export function ShareIndicator({ + entry, + onShare, +}: { + entry: Pick; + /** Fehlt sie (Teilen ausgeschaltet), bleibt auch das ausgehende Kennzeichen ein Symbol. */ + onShare?: () => void; +}) { + const t = useTranslations('nextcloudFiles.share.indicator'); + const outgoing = entry.shareTypes.length > 0; + const incoming = !outgoing && entry.permissions.includes('S'); + if (!outgoing && !incoming) return null; + + if (incoming) { + return ( + + + {t('incoming')} + + ); + } + + const Icon = entry.shareTypes.includes(3) ? LinkIcon : UsersIcon; + const label = t('outgoing', { name: entry.name }); + if (!onShare) { + return ( + + + {label} + + ); + } + return ( + + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx index d03fe1a..fe98753 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx @@ -161,6 +161,43 @@ export const CloudIcon = (p: P) => ( ); +// Teilen (quick-261009-dkv) +export const ShareIcon = (p: P) => ( + + + + + + + +); +export const LinkIcon = (p: P) => ( + + + + +); +export const UserIcon = (p: P) => ( + + + + +); +export const UsersIcon = (p: P) => ( + + + + + + +); +export const CopyIcon = (p: P) => ( + + + + +); + export const FileGlyph = (p: P) => ( diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx index 772a98d..176f23b 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx @@ -117,6 +117,7 @@ beforeEach(() => { permissions: 'RGDNVCK', hasPreview: false, favorite: false, + shareTypes: [], }, ], quota: { used: 0, available: null }, diff --git a/apps/web/src/components/nextcloud-files/error-text.ts b/apps/web/src/components/nextcloud-files/error-text.ts index a6640d0..6c00b24 100644 --- a/apps/web/src/components/nextcloud-files/error-text.ts +++ b/apps/web/src/components/nextcloud-files/error-text.ts @@ -21,6 +21,18 @@ const KNOWN = new Set([ 'invalidPath', 'nextcloudUnavailable', 'selectionTooLarge', + // Teilen (quick-261009-dkv) + 'sharingDisabled', + 'linkSharingDisabled', + 'shareAccessInvalid', + 'shareRecipientInvalid', + 'shareAlreadyExists', + 'sharePasswordRequired', + 'sharePasswordRejected', + 'shareExpiryRequired', + 'shareExpiryInvalid', + 'shareRejected', + 'shareNotFound', ]); export interface ErrorLike { @@ -29,6 +41,17 @@ export interface ErrorLike { extra?: Record; } +/** + * Die Meldung der Nextcloud selbst (uebersetzt in der Sprache ihres Benutzers), falls die API + * sie mitgeschickt hat. Nur als zweite Zeile unter dem eigenen Text zeigen, nie auswerten. + */ +export function ncMessageOf(error: ErrorLike): string | null { + const raw = error.extra?.ncMessage; + if (typeof raw !== 'string') return null; + const text = raw.trim(); + return text === '' ? null : text; +} + export function toErrorLike(err: unknown): ErrorLike { if (err instanceof NextcloudFilesRequestError) { return { code: err.code, message: err.message, extra: err.extra }; @@ -42,13 +65,16 @@ export function toErrorLike(err: unknown): ErrorLike { */ export function errorText(t: CodesTranslator, error: ErrorLike, locale: string): string { const code = error.code ?? ''; - if (code === 'nextcloudLocked') { + if (code === 'nextcloudLocked' || code === 'tooManyShares') { const seconds = Number(error.extra?.retryAfterSeconds); const minutes = Math.max( 1, Math.ceil((Number.isFinite(seconds) && seconds > 0 ? seconds : 60) / 60), ); - return t('nextcloudLocked', { minutes }); + return t(code, { minutes }); + } + if (code === 'sharingDisabled' && error.extra?.scope === 'groups') { + return t('sharingDisabledGroups'); } if (code === 'quotaExceeded' && typeof error.extra?.free === 'number') { return t('quotaExceededFree', { free: formatSize(error.extra.free, locale) }); diff --git a/apps/web/src/components/nextcloud-files/share-policy.test.ts b/apps/web/src/components/nextcloud-files/share-policy.test.ts new file mode 100644 index 0000000..4b1682f --- /dev/null +++ b/apps/web/src/components/nextcloud-files/share-policy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import type { NcSharePolicy } from '@/lib/nextcloud-files-api'; +import { + accessOptions, + generatePassword, + type RandomFill, + type ShareTarget, + targetFromEntry, +} from './share-policy'; + +const POLICY: NcSharePolicy = { + enabled: true, + groupsEnabled: true, + links: { + enabled: true, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: true, + multipleLinks: true, + }, + internalExpiry: { defaultDays: null, enforced: false }, + minSearchLength: 0, + passwordMinLength: 10, +}; + +const folder = (writable: boolean): ShareTarget => ({ + path: '/Projekte', + name: 'Projekte', + type: 'folder', + mime: null, + writable, +}); +const file = (writable: boolean): ShareTarget => ({ + path: '/Bericht.txt', + name: 'Bericht.txt', + type: 'file', + mime: 'text/plain', + writable, +}); + +describe('targetFromEntry', () => { + const base = { path: '/P', name: 'P', type: 'folder' as const, mime: null }; + it('beschreibbar bei W, C oder K', () => { + expect(targetFromEntry({ ...base, permissions: 'RGDNVCK' }).writable).toBe(true); + expect(targetFromEntry({ ...base, permissions: 'RGDNVW' }).writable).toBe(true); + expect(targetFromEntry({ ...base, permissions: 'RGC' }).writable).toBe(true); + expect(targetFromEntry({ ...base, permissions: 'RG' }).writable).toBe(false); + expect(targetFromEntry({ ...base, permissions: '' }).writable).toBe(false); + }); +}); + +describe('accessOptions', () => { + it('Person und Gruppe: beschreibbarer Ordner [Ansehen, Bearbeiten], sonst nur Ansehen', () => { + expect(accessOptions(folder(true), 'user', POLICY)).toEqual(['view', 'edit']); + expect(accessOptions(folder(true), 'group', POLICY)).toEqual(['view', 'edit']); + expect(accessOptions(folder(false), 'user', POLICY)).toEqual(['view']); + expect(accessOptions(file(true), 'user', POLICY)).toEqual(['view', 'edit']); + }); + + it('Link: Ordner mit erlaubtem Hochladen [Ansehen, Bearbeiten, Nur hochladen], Datei ohne Hochladen', () => { + expect(accessOptions(folder(true), 'link', POLICY)).toEqual(['view', 'edit', 'upload']); + expect(accessOptions(file(true), 'link', POLICY)).toEqual(['view', 'edit']); + const noUpload = { ...POLICY, links: { ...POLICY.links, uploadAllowed: false } }; + expect(accessOptions(folder(true), 'link', noUpload)).toEqual(['view']); + expect(accessOptions(folder(true), 'link', null)).toEqual(['view']); + }); +}); + +/** Deterministische Zufallsquelle fuer die Tests (Zaehler statt Zufall). */ +function counter(start = 0): RandomFill { + let n = start; + return (array) => { + for (let i = 0; i < array.length; i += 1) { + n = (n * 1103515245 + 12345) >>> 0; + array[i] = n; + } + return array; + }; +} + +describe('generatePassword', () => { + it('mindestens 20 Zeichen, von der Mindestlaenge der Nextcloud gehoben, bei 64 gedeckelt', () => { + expect(generatePassword(null, counter(1))).toHaveLength(20); + expect(generatePassword(10, counter(2))).toHaveLength(20); + expect(generatePassword(32, counter(3))).toHaveLength(32); + expect(generatePassword(500, counter(4))).toHaveLength(64); + }); + + it('enthaelt Gross-, Kleinbuchstaben, Ziffer und Sonderzeichen, nie aehnliche Zeichen', () => { + for (let seed = 0; seed < 50; seed += 1) { + const pw = generatePassword(null, counter(seed)); + expect(pw).toMatch(/[A-Z]/); + expect(pw).toMatch(/[a-z]/); + expect(pw).toMatch(/[0-9]/); + expect(pw).toMatch(/[^A-Za-z0-9]/); + expect(pw).not.toMatch(/[0O1lIo]/); + } + }); + + it('mit der echten Zufallsquelle: zwei Aufrufe ergeben verschiedene Werte', () => { + expect(generatePassword(null)).not.toBe(generatePassword(null)); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/share-policy.ts b/apps/web/src/components/nextcloud-files/share-policy.ts new file mode 100644 index 0000000..0e0e1ee --- /dev/null +++ b/apps/web/src/components/nextcloud-files/share-policy.ts @@ -0,0 +1,100 @@ +import type { NcEntry, NcSharePolicy } from '@/lib/nextcloud-files-api'; + +/** + * Reine Hilfen fuer das Teilen (quick-261009-dkv): was der Dialog anbietet, wird hier aus dem + * Eintrag und den Freigaberegeln der Nextcloud abgeleitet. Die API prueft dieselben Regeln + * verbindlich nach; der Browser spiegelt sie nur, damit der Benutzer nie eine Auswahl trifft, + * die abgelehnt wuerde. + */ + +export type ShareAccessChoice = 'view' | 'edit' | 'upload'; +export type ShareAudience = 'user' | 'group' | 'link'; + +/** Das, was geteilt wird: Pfad, Name, Art und ob der Eintrag selbst beschreibbar ist. */ +export interface ShareTarget { + path: string; + name: string; + type: 'file' | 'folder'; + mime: string | null; + writable: boolean; +} + +/** Nextcloud-Buchstaben W (schreiben), C und K (anlegen): nur dann ist "Bearbeiten" moeglich. */ +export function targetFromEntry( + entry: Pick, +): ShareTarget { + return { + path: entry.path, + name: entry.name, + type: entry.type, + mime: entry.mime, + writable: /[WCK]/.test(entry.permissions), + }; +} + +/** + * Berechtigungen, die fuer diesen Eintrag und diese Empfaengerart angeboten werden. Personen + * und Gruppen: Ansehen, bei beschreibbarem Eintrag auch Bearbeiten. Links zusaetzlich nur, + * wenn die Nextcloud oeffentliches Hochladen erlaubt; "Nur hochladen" nur bei Ordnern. + */ +export function accessOptions( + target: ShareTarget, + audience: ShareAudience, + policy: NcSharePolicy | null, +): ShareAccessChoice[] { + const options: ShareAccessChoice[] = ['view']; + if (audience === 'link') { + if (!policy?.links.uploadAllowed) return options; + if (target.writable) options.push('edit'); + if (target.type === 'folder') options.push('upload'); + return options; + } + if (target.writable) options.push('edit'); + return options; +} + +// --- Passwort erzeugen -------------------------------------------------------------------------- + +/** Ohne Zeichen, die sich ähneln (0 O o, 1 l I). */ +const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; +const LOWER = 'abcdefghijkmnpqrstuvwxyz'; +const DIGITS = '23456789'; +const SPECIAL = '!#$%&*+-=?@_'; +const MIN_GENERATED = 20; +const MAX_GENERATED = 64; + +export type RandomFill = (array: Uint32Array) => Uint32Array; + +const cryptoFill: RandomFill = (array) => crypto.getRandomValues(array); + +/** Gleichverteilte Zufallszahl in [0, max) ohne Modulo-Verzerrung (Rejection Sampling). */ +function randomBelow(max: number, fill: RandomFill): number { + const limit = Math.floor(0x1_0000_0000 / max) * max; + const buffer = new Uint32Array(1); + for (;;) { + fill(buffer); + if (buffer[0] < limit) return buffer[0] % max; + } +} + +function pick(chars: string, fill: RandomFill): string { + return chars[randomBelow(chars.length, fill)]; +} + +/** + * Zufallspasswort aus der Betriebssystem-Zufallsquelle: mindestens 20 Zeichen (oder die + * Mindestlaenge der Nextcloud, hoechstens 64), je mindestens ein Gross- und Kleinbuchstabe, + * eine Ziffer und ein Sonderzeichen. Nextcloud prueft das Passwort trotzdem selbst. + */ +export function generatePassword(minLength: number | null, fill: RandomFill = cryptoFill): string { + const length = Math.min(MAX_GENERATED, Math.max(MIN_GENERATED, minLength ?? 0)); + const all = UPPER + LOWER + DIGITS + SPECIAL; + const chars = [pick(UPPER, fill), pick(LOWER, fill), pick(DIGITS, fill), pick(SPECIAL, fill)]; + while (chars.length < length) chars.push(pick(all, fill)); + // Fisher-Yates, damit die vier Pflichtzeichen nicht immer vorn stehen. + for (let i = chars.length - 1; i > 0; i -= 1) { + const j = randomBelow(i + 1, fill); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} diff --git a/apps/web/src/lib/nextcloud-files-api.test.ts b/apps/web/src/lib/nextcloud-files-api.test.ts index 1e4d247..2c282a7 100644 --- a/apps/web/src/lib/nextcloud-files-api.test.ts +++ b/apps/web/src/lib/nextcloud-files-api.test.ts @@ -3,12 +3,18 @@ import { checkDownload, checkZip, createFolder, + createShare, deleteEntry, + deleteShare, downloadUrl, + getSharePolicy, listFolder, + listSharesForPath, moveEntry, NextcloudFilesRequestError, previewUrl, + searchSharees, + updateShare, ZIP_MAX_NAMES, zipFormFields, zipPostUrl, @@ -150,3 +156,74 @@ describe('nextcloud-files-api — Herunterladen', () => { }); }); }); + +describe('nextcloud-files-api — Teilen', () => { + it('getSharePolicy: GET /shares/policy', async () => { + const fetchMock = mockFetch(200, { enabled: true }); + await getSharePolicy(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/shares/policy`); + expect(init.method).toBe('GET'); + expect(init.credentials).toBe('include'); + }); + + it('listSharesForPath codiert den Pfad nur in der Query', async () => { + const fetchMock = mockFetch(200, { + path: '/Ärger 100%', + shares: [], + hidden: 0, + truncated: false, + }); + await listSharesForPath('/Ärger 100%'); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/shares/by-path?path=%2F%C3%84rger%20100%25`); + }); + + it('searchSharees sendet Begriff und Art in der Query', async () => { + const fetchMock = mockFetch(200, { sharees: [] }); + await searchSharees('ben', 'folder'); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/sharees?term=ben&itemType=folder`); + }); + + it('createShare sendet genau die Eingabe als JSON (nie eine Maske)', async () => { + const fetchMock = mockFetch(201, { id: '1' }); + await createShare({ path: '/P', kind: 'user', shareWith: 'ben', access: 'view' }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/shares`); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"path":"/P","kind":"user","shareWith":"ben","access":"view"}'); + }); + + it('updateShare und deleteShare tragen die Kennung im Pfad', async () => { + const put = mockFetch(200, { id: '17' }); + await updateShare('17', { access: 'edit' }); + const [putUrl, putInit] = put.mock.calls[0] as [string, RequestInit]; + expect(putUrl).toBe(`${API}/modules/nextcloud-files/shares/17`); + expect(putInit.method).toBe('PUT'); + expect(putInit.body).toBe('{"access":"edit"}'); + + const del = mockFetch(200, { deleted: true }); + await deleteShare('17'); + const [delUrl, delInit] = del.mock.calls[0] as [string, RequestInit]; + expect(delUrl).toBe(`${API}/modules/nextcloud-files/shares/17`); + expect(delInit.method).toBe('DELETE'); + expect(delInit.body).toBeUndefined(); + }); + + it('Fehler der Freigaben tragen ncMessage in extra', async () => { + mockFetch(422, { + code: 'shareRejected', + message: 'Nextcloud hat diese Freigabe abgelehnt.', + ncMessage: 'Nein', + }); + const err = await createShare({ + path: '/P', + kind: 'user', + shareWith: 'b', + access: 'view', + }).catch((e) => e); + expect(err.code).toBe('shareRejected'); + expect(err.extra).toEqual({ ncMessage: 'Nein' }); + }); +}); diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts index b0cb7e9..3c5ca97 100644 --- a/apps/web/src/lib/nextcloud-files-api.ts +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -209,6 +209,8 @@ export interface NcEntry { permissions: string; hasPreview: boolean; favorite: boolean; + /** Freigabearten des Eintrags (0 Person, 1 Gruppe, 3 Link ...); leer = nicht geteilt. */ + shareTypes: number[]; } export interface NcQuota { @@ -315,3 +317,111 @@ export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: t json: { dir, names, check: true }, }); } + +// --- Teilen (quick-261009-dkv) ------------------------------------------------------------------- + +export type NcShareKind = 'user' | 'group' | 'link'; +/** `custom`: eine Berechtigung, die sich nicht auf Ansehen/Bearbeiten/Hochladen abbilden laesst. */ +export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom'; + +export interface NcShare { + id: string; + kind: NcShareKind; + path: string; + name: string; + itemType: 'file' | 'folder'; + mime: string | null; + itemWritable: boolean; + permissions: number; + access: NcShareAccess; + shareWith: string | null; + shareWithName: string | null; + ownerId: string | null; + ownerName: string | null; + canEdit: boolean; + canDelete: boolean; + /** `YYYY-MM-DD` oder null. */ + expiration: string | null; + label: string; + /** Nur bei eigenen Links; Tessera ruft sie nie auf. */ + url: string | null; + hasPassword: boolean; + target: string; + sharedAt: string | null; + pending?: boolean; +} + +export interface NcSharee { + kind: 'user' | 'group'; + id: string; + label: string; + detail: string | null; +} + +/** Freigaberegeln der Nextcloud fuer den angemeldeten Benutzer (frisch gelesen). */ +export interface NcSharePolicy { + enabled: boolean; + groupsEnabled: boolean; + links: { + enabled: boolean; + passwordRequired: boolean; + passwordSuggested: boolean; + expiryDefaultDays: number | null; + expiryEnforced: boolean; + uploadAllowed: boolean; + multipleLinks: boolean; + }; + internalExpiry: { defaultDays: number | null; enforced: boolean }; + minSearchLength: number; + passwordMinLength: number | null; +} + +export interface NcShareList { + path: string; + shares: NcShare[]; + /** Freigaben anderer Arten (E-Mail, Server ...), nur als Zahl. */ + hidden: number; + truncated: boolean; +} + +export interface CreateShareInput { + path: string; + kind: 'user' | 'group'; + shareWith: string; + access: 'view' | 'edit' | 'upload'; +} + +export interface UpdateShareInput { + access?: 'view' | 'edit' | 'upload'; +} + +export function getSharePolicy(): Promise { + return request('/shares/policy'); +} + +/** Freigaben eines Eintrags; der Pfad geht nur in der Query. */ +export function listSharesForPath(path: string): Promise { + return request(`/shares/by-path?path=${encodeURIComponent(path)}`); +} + +/** Personen und Gruppen, die die Nextcloud zum Suchbegriff kennt. */ +export function searchSharees( + term: string, + itemType: 'file' | 'folder', +): Promise<{ sharees: NcSharee[] }> { + return request<{ sharees: NcSharee[] }>( + `/sharees?term=${encodeURIComponent(term)}&itemType=${encodeURIComponent(itemType)}`, + ); +} + +export function createShare(input: CreateShareInput): Promise { + return request('/shares', { method: 'POST', json: input }); +} + +export function updateShare(id: string, input: UpdateShareInput): Promise { + return request(`/shares/${encodeURIComponent(id)}`, { method: 'PUT', json: input }); +} + +export function deleteShare(id: string): Promise<{ deleted: true }> { + return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index ca53b53..fd5571a 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2471,6 +2471,7 @@ "downloadZip": "Als ZIP herunterladen", "rename": "Umbenennen", "move": "Verschieben", + "share": "Teilen", "openInNextcloud": "In Nextcloud öffnen", "delete": "Löschen" }, @@ -2572,6 +2573,38 @@ "skip": "Überspringen" } }, + "share": { + "title": "„{name}“ teilen", + "loading": "Freigaben werden geladen …", + "done": "Fertig", + "hidden": "{count, plural, one {Eine weitere Freigabe, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.} other {# weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.}}", + "people": { + "title": "Personen und Gruppen", + "searchLabel": "Person oder Gruppe suchen", + "searchPlaceholder": "Name eingeben", + "newAccess": "Berechtigung für die nächste Freigabe", + "searching": "Suche läuft …", + "minChars": "Geben Sie mindestens {count} Zeichen ein.", + "noResults": "Niemand gefunden.", + "results": "Suchergebnisse", + "group": "Gruppe", + "alreadyShared": "bereits geteilt", + "empty": "Noch mit niemandem geteilt.", + "accessFor": "Berechtigung für {name}", + "remove": "Freigabe für {name} entfernen", + "expires": "gültig bis {date}" + }, + "access": { + "view": "Ansehen", + "edit": "Bearbeiten", + "upload": "Nur hochladen", + "custom": "Eigene Berechtigung" + }, + "indicator": { + "outgoing": "Freigaben von „{name}“ öffnen", + "incoming": "Mit Ihnen geteilt" + } + }, "codes": { "lengthRequired": "Die Größe der Datei ließ sich nicht bestimmen. Bitte versuchen Sie es erneut.", "chunkTooLarge": "Ein Teilstück der Datei war zu groß. Bitte laden Sie die Seite neu und versuchen Sie es erneut.", @@ -2590,7 +2623,21 @@ "invalidPath": "Dieser Name ist nicht erlaubt.", "nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.", "generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut.", - "selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter." + "selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter.", + "sharingDisabled": "Teilen ist in Ihrer Nextcloud ausgeschaltet.", + "sharingDisabledGroups": "Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.", + "linkSharingDisabled": "Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.", + "shareAccessInvalid": "Diese Berechtigung ist für diesen Eintrag nicht möglich.", + "shareRecipientInvalid": "Diese Person oder Gruppe kennt Ihre Nextcloud nicht.", + "shareAlreadyExists": "Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.", + "sharePasswordRequired": "Ihre Nextcloud verlangt für Links ein Passwort.", + "sharePasswordRejected": "Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.", + "shareExpiryRequired": "Ihre Nextcloud verlangt für Links ein Ablaufdatum.", + "shareExpiryInvalid": "Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.", + "shareRejected": "Nextcloud hat diese Freigabe abgelehnt.", + "shareNotFound": "Diese Freigabe gibt es nicht mehr.", + "tooManyShares": "Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.", + "ncDetail": "Meldung der Nextcloud: {detail}" } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 9856c29..f9e6bec 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2471,6 +2471,7 @@ "downloadZip": "Download as ZIP", "rename": "Rename", "move": "Move", + "share": "Share", "openInNextcloud": "Open in Nextcloud", "delete": "Delete" }, @@ -2572,6 +2573,38 @@ "skip": "Skip" } }, + "share": { + "title": "Share “{name}”", + "loading": "Loading shares …", + "done": "Done", + "hidden": "{count, plural, one {One more share, for example by email, is only visible in Nextcloud.} other {# more shares, for example by email, are only visible in Nextcloud.}}", + "people": { + "title": "People and groups", + "searchLabel": "Search for a person or group", + "searchPlaceholder": "Enter a name", + "newAccess": "Permission for the next share", + "searching": "Searching …", + "minChars": "Enter at least {count} characters.", + "noResults": "Nobody found.", + "results": "Search results", + "group": "Group", + "alreadyShared": "already shared", + "empty": "Not shared with anyone yet.", + "accessFor": "Permission for {name}", + "remove": "Remove share for {name}", + "expires": "valid until {date}" + }, + "access": { + "view": "View", + "edit": "Edit", + "upload": "Upload only", + "custom": "Custom permission" + }, + "indicator": { + "outgoing": "Open shares of “{name}”", + "incoming": "Shared with you" + } + }, "codes": { "lengthRequired": "The size of the file could not be determined. Please try again.", "chunkTooLarge": "A part of the file was too large. Please reload the page and try again.", @@ -2590,7 +2623,21 @@ "invalidPath": "This name is not allowed.", "nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.", "generic": "That did not work. Please try again.", - "selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder." + "selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder.", + "sharingDisabled": "Sharing is switched off in your Nextcloud.", + "sharingDisabledGroups": "Sharing with groups is switched off in your Nextcloud.", + "linkSharingDisabled": "Public links are switched off in your Nextcloud.", + "shareAccessInvalid": "This permission is not possible for this item.", + "shareRecipientInvalid": "Your Nextcloud does not know this person or group.", + "shareAlreadyExists": "The item is already shared this way. Change the existing share instead.", + "sharePasswordRequired": "Your Nextcloud requires a password for links.", + "sharePasswordRejected": "Nextcloud rejects this password. Please choose a longer or less common password.", + "shareExpiryRequired": "Your Nextcloud requires an expiry date for links.", + "shareExpiryInvalid": "Your Nextcloud does not allow this expiry date. It must not be in the past or after the latest permitted date.", + "shareRejected": "Nextcloud rejected this share.", + "shareNotFound": "This share no longer exists.", + "tooManyShares": "You have created many shares in a short time. Please wait {minutes, plural, one {# minute} other {# minutes}}.", + "ncDetail": "Message from Nextcloud: {detail}" } } } diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index ea6c635..99630ab 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -239,4 +239,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ // quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“ 'ausstellen', 'vergisst', + // quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“ + 'Suchergebnisse', ];