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}
)}
- 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 (
+
+ );
+}
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',
];
|