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
@@ -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 },