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) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:52:35 +02:00
parent 207d37b8d6
commit bf893afc61
19 changed files with 1640 additions and 12 deletions
@@ -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);
});
@@ -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))) {
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
@@ -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> = {}): 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(<FileBrowser serverUrl={null} onExpired={onExpired} sharingEnabled={false} />);
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));
});
});
@@ -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: <ShareIcon />,
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' && (
<ShareDialog
target={dialog.target}
onChanged={() => void load(pathRef.current, { quiet: true })}
onExpired={() => onExpiredRef.current()}
onClose={() => setDialog(null)}
/>
)}
</div>
);
}
@@ -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}
</p>
)}
<p
className="mt-0.5 text-xs tabular-nums text-muted-foreground"
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
>
{formatSize(entry.size, locale)}
</p>
<div className="mt-0.5 flex items-center gap-1.5">
<p
className="text-xs tabular-nums text-muted-foreground"
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
>
{formatSize(entry.size, locale)}
</p>
<ShareIndicator
entry={entry}
onShare={onShare ? () => onShare(entry) : undefined}
/>
</div>
</div>
</div>
);
@@ -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}
</a>
)}
<ShareIndicator
entry={entry}
onShare={onShare ? () => onShare(entry) : undefined}
/>
</span>
</td>
<td className="whitespace-nowrap py-0 pr-1 text-right text-[13px] tabular-nums text-muted-foreground">
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
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> = {}): 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<typeof vi.fn>;
let onChanged: ReturnType<typeof vi.fn>;
let onExpired: ReturnType<typeof vi.fn>;
function mount(target: ShareTarget = FOLDER) {
return render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ShareDialog target={target} onClose={onClose} onChanged={onChanged} onExpired={onExpired} />
</NextIntlClientProvider>,
);
}
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);
});
});
@@ -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<NcSharePolicy | null>(null);
const [shares, setShares] = useState<NcShare[]>([]);
const [hidden, setHidden] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorLike | null>(null);
const [busy, setBusy] = useState(false);
const [term, setTerm] = useState('');
const [results, setResults] = useState<NcSharee[]>([]);
const [searchedFor, setSearchedFor] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [newAccess, setNewAccess] = useState<ShareAccessChoice>('view');
const input = useRef<HTMLInputElement>(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<void>) => {
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<HTMLInputElement>) => {
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 (
<Dialog
wide
title={t('title', { name: target.name })}
onClose={onClose}
footer={
<button type="button" className="btn btn-primary" onClick={onClose}>
{t('done')}
</button>
}
>
{loading ? (
<p role="status" className="py-4 text-sm text-muted-foreground">
{t('loading')}
</p>
) : !enabled ? (
<p role="alert" className="py-2 text-sm text-foreground">
{error ? errorText(tCodes, error, locale) : tCodes('sharingDisabled')}
</p>
) : (
<div className="space-y-5" aria-busy={busy}>
<section aria-labelledby={`${ids}-people`}>
<h3 id={`${ids}-people`} className="text-sm font-semibold text-foreground">
{t('people.title')}
</h3>
<label htmlFor={inputId} className="mt-3 block text-sm text-muted-foreground">
{t('people.searchLabel')}
</label>
<div className="mt-1.5 flex flex-wrap gap-2 sm:flex-nowrap">
<input
ref={input}
id={inputId}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={active >= 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"
/>
<select
aria-label={t('people.newAccess')}
value={newAccess}
disabled={busy}
onChange={(e) => setNewAccess(e.target.value as ShareAccessChoice)}
className={SELECT_CLASS}
>
{options.map((o) => (
<option key={o} value={o}>
{t(`access.${o}`)}
</option>
))}
</select>
</div>
<p
role="status"
aria-live="polite"
className="mt-1.5 min-h-5 text-xs text-muted-foreground"
>
{searching
? t('people.searching')
: tooShort
? t('people.minChars', { count: minChars })
: noResults
? t('people.noResults')
: ''}
</p>
{open && results.length > 0 && (
<ul
id={listId}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Ergebnisliste des Suchfelds (ARIA-Combobox-Muster)
role="listbox"
aria-label={t('people.results')}
className="mt-1 max-h-56 overflow-y-auto rounded border border-border bg-card"
>
{results.map((s, i) => {
const already = sharedKeys.has(`${s.kind}:${s.id}`);
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: Tastatur ueber das Suchfeld (Pfeile, Enter)
<li
key={`${s.kind}:${s.id}`}
id={optionId(i)}
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Option der Combobox
role="option"
tabIndex={-1}
aria-selected={active === i}
aria-disabled={already || undefined}
onMouseDown={(e) => 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' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
<span className="min-w-0 flex-1 truncate text-foreground">{s.label}</span>
{s.kind === 'group' && (
<span className="text-xs text-muted-foreground">{t('people.group')}</span>
)}
{s.kind === 'user' && s.detail && s.detail !== s.label && (
<span className="truncate text-xs text-muted-foreground">{s.detail}</span>
)}
{already && (
<span className="text-xs text-muted-foreground">
{t('people.alreadyShared')}
</span>
)}
</li>
);
})}
</ul>
)}
{people.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{t('people.empty')}</p>
) : (
<ul className="mt-3 divide-y divide-border/70 rounded border border-border">
{people.map((share) => {
const rowOptions = options.includes(share.access as ShareAccessChoice)
? options
: [share.access, ...options];
return (
<li
key={share.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2"
>
<span
aria-hidden="true"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-well text-muted-foreground"
>
{share.kind === 'group' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{nameOf(share)}
{share.kind === 'group' && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{t('people.group')}
</span>
)}
</p>
{share.expiration && (
<p className="text-xs text-muted-foreground">
{t('people.expires', { date: dateText(share.expiration, locale) })}
</p>
)}
</div>
<select
aria-label={t('people.accessFor', { name: nameOf(share) })}
value={share.access}
disabled={busy || !share.canEdit}
onChange={(e) =>
void changeAccess(share, e.target.value as ShareAccessChoice)
}
className={SELECT_CLASS}
>
{rowOptions.map((o) => (
<option key={o} value={o} disabled={o === 'custom'}>
{t(`access.${o}`)}
</option>
))}
</select>
<button
type="button"
aria-label={t('people.remove', { name: nameOf(share) })}
title={t('people.remove', { name: nameOf(share) })}
disabled={busy || !share.canDelete}
onClick={() => void remove(share)}
className="btn btn-subtle btn-icon h-8 w-8 text-muted-foreground hover:text-foreground"
>
<CloseIcon size={16} />
</button>
</li>
);
})}
</ul>
)}
{hidden > 0 && (
<p className="mt-2 text-xs text-muted-foreground">{t('hidden', { count: hidden })}</p>
)}
</section>
</div>
)}
{error && enabled && (
<div role="alert" className="mt-4 text-sm text-status-down-fg">
<p>{errorText(tCodes, error, locale)}</p>
{detail && (
<p className="mt-0.5 text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
)}
</div>
)}
{error && !enabled && !loading && detail && (
<p className="mt-1 text-sm text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
)}
</Dialog>
);
}
@@ -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<NcEntry, 'name' | 'shareTypes' | 'permissions'>;
/** 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 (
<span
data-testid="share-incoming"
title={t('incoming')}
className="inline-flex shrink-0 items-center text-muted-foreground"
>
<UsersIcon size={15} />
<span className="sr-only">{t('incoming')}</span>
</span>
);
}
const Icon = entry.shareTypes.includes(3) ? LinkIcon : UsersIcon;
const label = t('outgoing', { name: entry.name });
if (!onShare) {
return (
<span
data-testid="share-outgoing"
title={label}
className="inline-flex shrink-0 items-center text-muted-foreground"
>
<Icon size={15} />
<span className="sr-only">{label}</span>
</span>
);
}
return (
<button
type="button"
tabIndex={-1}
data-testid="share-outgoing"
aria-label={label}
title={label}
onClick={(e) => {
e.stopPropagation();
onShare();
}}
onDoubleClick={(e) => e.stopPropagation()}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon size={15} />
</button>
);
}
@@ -161,6 +161,43 @@ export const CloudIcon = (p: P) => (
</Icon>
);
// Teilen (quick-261009-dkv)
export const ShareIcon = (p: P) => (
<Icon {...p}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
</Icon>
);
export const LinkIcon = (p: P) => (
<Icon {...p}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</Icon>
);
export const UserIcon = (p: P) => (
<Icon {...p}>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Icon>
);
export const UsersIcon = (p: P) => (
<Icon {...p}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Icon>
);
export const CopyIcon = (p: P) => (
<Icon {...p}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</Icon>
);
export const FileGlyph = (p: P) => (
<Icon {...p}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
@@ -117,6 +117,7 @@ beforeEach(() => {
permissions: 'RGDNVCK',
hasPreview: false,
favorite: false,
shareTypes: [],
},
],
quota: { used: 0, available: null },
@@ -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<string, unknown>;
}
/**
* 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) });
@@ -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));
});
});
@@ -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<NcEntry, 'path' | 'name' | 'type' | 'mime' | 'permissions'>,
): 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('');
}
@@ -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' });
});
});
+110
View File
@@ -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<NcSharePolicy> {
return request<NcSharePolicy>('/shares/policy');
}
/** Freigaben eines Eintrags; der Pfad geht nur in der Query. */
export function listSharesForPath(path: string): Promise<NcShareList> {
return request<NcShareList>(`/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<NcShare> {
return request<NcShare>('/shares', { method: 'POST', json: input });
}
export function updateShare(id: string, input: UpdateShareInput): Promise<NcShare> {
return request<NcShare>(`/shares/${encodeURIComponent(id)}`, { method: 'PUT', json: input });
}
export function deleteShare(id: string): Promise<{ deleted: true }> {
return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
+48 -1
View File
@@ -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}"
}
}
}
+48 -1
View File
@@ -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}"
}
}
}
@@ -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',
];