fix(quick-261009-dkv): Teilen-Dialog, Freigabe-Ansichten und Dateiansicht nach der Pruefung

- Link aendern: nicht angebotene Berechtigung bleibt unberuehrt (Aktuell: ...), Zugriff nur bei ausdruecklicher Wahl gesendet
- Fehler je Abschnitt zugeordnet und geloescht; Aendern nur bei aenderbarer Freigabe; Kopiert-Anzeige laeuft ab
- Freigabe-Ansichten: stabiler Rueckruf, nur das juengste Laden gilt; Weitergaben mit Hinweis
- Ablaufgrenzen nach Serverdatum, Passwort-Erzeugung bis 256 Zeichen
- Dateiansicht blendet Neuer Ordner, Hochladen, Ablegen, Umbenennen, Verschieben und Loeschen nach den Buchstaben der Nextcloud aus
- Import-Reihenfolge nach biome check, Anleitungen und Changelog (10 statt 15 Freigaben, Suche ab einem oder zwei Zeichen)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 12:21:31 +02:00
parent 78f6cf358d
commit d487a00955
24 changed files with 913 additions and 130 deletions
@@ -70,7 +70,7 @@ function entry(path: string, over: Partial<NcEntry> = {}): NcEntry {
};
}
const folder = (path: string, over: Partial<NcEntry> = {}) =>
entry(path, { type: 'folder', size: 4096, ...over });
entry(path, { type: 'folder', size: 4096, permissions: 'RGDNVCK', ...over });
function listing(path: string, entries: NcEntry[], over: Partial<NcListing> = {}): NcListing {
return {
@@ -783,3 +783,152 @@ describe('FileBrowser — Teilen (quick-261009-dkv)', () => {
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
});
});
describe('FileBrowser — Rechte des geöffneten Ordners (Messwerte der Nextcloud 34)', () => {
const dropFile = () => {
const file = new File(['x'], 'neu.txt');
return {
file,
dt: {
types: ['Files'],
files: [file],
items: [{ kind: 'file', getAsFile: () => file }],
dropEffect: 'none',
},
};
};
/** Eingehender Nur-Ansehen-Ordner: er selbst `SGDN`, sein Inhalt `SG`. */
function readOnlyFolder(empty = false) {
mockList.mockImplementation(async (p: string) =>
listing(
p,
empty
? []
: [folder('/Ansicht', { permissions: 'SG' }), entry('/Plan.pdf', { permissions: 'SG' })],
{ permissions: 'SGDN' },
),
);
}
it('eigener Ordner (RGDNVCK): Neuer Ordner und Hochladen sind da, kein Hinweis "Nur ansehen"', async () => {
mockList.mockImplementation(async (p: string) =>
listing(p, [folder('/Projekte')], { permissions: 'RGDNVCK' }),
);
await mount();
expect(screen.getByRole('button', { name: 'Neuer Ordner' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Hochladen' })).toBeTruthy();
expect(screen.queryByText('Nur ansehen')).toBeNull();
});
it('Antwort ohne Buchstaben des Ordners: nichts wird ausgeblendet (unbekannt ist nicht verboten)', async () => {
await mount();
expect(screen.getByRole('button', { name: 'Neuer Ordner' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Hochladen' })).toBeTruthy();
});
it('Nur-Ansehen-Ordner (SGDN): kein Neuer Ordner, kein Hochladen, dafuer der Hinweis "Nur ansehen"', async () => {
readOnlyFolder();
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
await screen.findByRole('row', { name: 'Plan.pdf' });
expect(screen.queryByRole('button', { name: 'Neuer Ordner' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Hochladen' })).toBeNull();
expect(screen.getByText('Nur ansehen')).toBeTruthy();
});
it('leerer Nur-Ansehen-Ordner: keine Einladung zum Hochladen und kein Hochladen-Knopf', async () => {
readOnlyFolder(true);
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
expect(await screen.findByText('Dieser Ordner ist leer.')).toBeTruthy();
expect(screen.getByText('Hier lässt sich nichts hinzufügen.')).toBeTruthy();
expect(screen.queryByText('Dateien hierher ziehen oder hochladen.')).toBeNull();
expect(screen.queryByRole('button', { name: 'Hochladen' })).toBeNull();
});
it('Ablegen in einen Nur-Ansehen-Ordner: keine Markierung, kein Hochladen, ein Hinweis; der Browser oeffnet die Datei nicht', async () => {
readOnlyFolder();
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
await screen.findByRole('row', { name: 'Plan.pdf' });
const { dt } = dropFile();
fireEvent.dragEnter(window, { dataTransfer: dt });
expect(screen.queryByTestId('nc-files-drop-overlay')).toBeNull();
const over = createEvent.dragOver(window, { dataTransfer: dt });
fireEvent(window, over);
expect(over.defaultPrevented).toBe(true);
expect(dt.dropEffect).toBe('none');
const drop = createEvent.drop(window, { dataTransfer: dt });
fireEvent(window, drop);
expect(drop.defaultPrevented).toBe(true);
await act(async () => {});
expect(mockUpload).not.toHaveBeenCalled();
expect(screen.getByText('In diesen Ordner dürfen Sie nichts ablegen.')).toBeTruthy();
});
it('Ablegen auf eine Ordnerzeile ohne Anlegerecht (SG) wird abgewiesen, auf eine mit (RGDNVCK) nicht', async () => {
mockList.mockImplementation(async (p: string) =>
listing(
p,
[folder('/Fest', { permissions: 'SG' }), folder('/Offen', { permissions: 'RGDNVCK' })],
{ permissions: 'RGDNVCK' },
),
);
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
await screen.findByRole('row', { name: 'Fest' });
const { dt } = dropFile();
const fest = screen.getByRole('row', { name: 'Fest' });
fireEvent.dragEnter(fest, { dataTransfer: dt });
fireEvent.dragOver(fest, { dataTransfer: dt });
expect(screen.queryByTestId('nc-files-drop-overlay')).toBeNull();
fireEvent.drop(fest, { dataTransfer: dt });
await act(async () => {});
expect(mockUpload).not.toHaveBeenCalled();
const offen = screen.getByRole('row', { name: 'Offen' });
fireEvent.dragEnter(offen, { dataTransfer: dt });
fireEvent.dragOver(offen, { dataTransfer: dt });
expect(screen.getByTestId('nc-files-drop-overlay').textContent).toContain('In „Offen“');
fireEvent.drop(offen, { dataTransfer: dt });
await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(1));
expect(mockUpload.mock.calls[0][1]).toBe('/Offen/neu.txt');
});
it('Eintraege ohne Rechte (SG): im Menue fehlen Umbenennen, Verschieben und Loeschen, Herunterladen bleibt', async () => {
readOnlyFolder();
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
const row = await screen.findByRole('row', { name: 'Plan.pdf' });
fireEvent.contextMenu(row, { clientX: 100, clientY: 100 });
const names = screen.getAllByRole('menuitem').map((m) => m.textContent);
expect(names).toContain('Herunterladen');
for (const gone of ['Umbenennen', 'Verschieben', 'Löschen']) {
expect(names).not.toContain(gone);
}
});
it('Eintraege mit allen Rechten behalten Umbenennen, Verschieben und Loeschen', async () => {
await mount();
fireEvent.contextMenu(screen.getByRole('row', { name: 'Bericht.pdf' }), {
clientX: 100,
clientY: 100,
});
const names = screen.getAllByRole('menuitem').map((m) => m.textContent);
for (const there of ['Umbenennen', 'Verschieben', 'Löschen']) {
expect(names).toContain(there);
}
});
it('Auswahlleiste ohne Rechte: Herunterladen ja, Verschieben und Loeschen nein; Entf und F2 tun nichts', async () => {
readOnlyFolder();
render(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
const row = await screen.findByRole('row', { name: 'Plan.pdf' });
fireEvent.click(row);
const bar = await screen.findByRole('toolbar');
expect(within(bar).getByRole('button', { name: /Herunterladen/ })).toBeTruthy();
expect(within(bar).queryByRole('button', { name: /Verschieben/ })).toBeNull();
expect(within(bar).queryByRole('button', { name: /Löschen/ })).toBeNull();
fireEvent.keyDown(row, { key: 'Delete' });
fireEvent.keyDown(row, { key: 'F2' });
await act(async () => {});
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
});
@@ -3,6 +3,13 @@
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { collectDrop, hasFiles, snapshotDrop } from '@/components/nextcloud-files/drop-entries';
import {
canCreateFolderIn,
canDelete,
canMove,
canRename,
canUploadTo,
} from '@/components/nextcloud-files/entry-permissions';
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';
@@ -32,6 +39,7 @@ import { FileList } from './FileList';
import {
DownloadIcon,
ExternalIcon,
FolderIcon,
MoveIcon,
OpenIcon,
PencilIcon,
@@ -356,6 +364,14 @@ export function FileBrowser({
});
}, [listing, sort, locale]);
// Was der geoeffnete Ordner selbst erlaubt (Buchstaben des Ordners): ein nur zum Ansehen
// geteilter Ordner bietet kein Anlegen und kein Hochladen an. Unbekannt heisst erlaubt.
const folderLetters = listing?.permissions ?? null;
const canUploadHere = canUploadTo(folderLetters);
const canNewFolderHere = canCreateFolderIn(folderLetters);
const dropRulesRef = useRef({ canUploadHere: true, entries: [] as NcEntry[] });
dropRulesRef.current = { canUploadHere, entries };
const order = useMemo(() => entries.map((e) => e.path), [entries]);
const selectedSet = useMemo(() => new Set(selection.selected), [selection.selected]);
const selectedEntries = useMemo(
@@ -450,10 +466,19 @@ export function FileBrowser({
const el = e.target instanceof Element ? e.target.closest('[data-drop-folder]') : null;
return el?.getAttribute('data-drop-folder') ?? pathRef.current;
};
// Darf in das Ziel (diesen Ordner oder eine Ordnerzeile) abgelegt werden? Unbekannte Ziele
// (Pfadleiste) bleiben erlaubt: dort entscheidet die Nextcloud.
const allowed = (e: DragEvent): boolean => {
const target = targetOf(e);
const rules = dropRulesRef.current;
if (target === pathRef.current) return rules.canUploadHere;
const row = rules.entries.find((x) => x.type === 'folder' && x.path === target);
return row ? canUploadTo(row.permissions) : true;
};
const onEnter = (e: DragEvent) => {
if (!hasFiles(e.dataTransfer)) return;
depth += 1;
setDropTarget(blocked(e) ? null : targetOf(e));
setDropTarget(blocked(e) || !allowed(e) ? null : targetOf(e));
};
const onOver = (e: DragEvent) => {
if (!hasFiles(e.dataTransfer)) return;
@@ -463,6 +488,11 @@ export function FileBrowser({
setDropTarget(null);
return;
}
if (!allowed(e)) {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
setDropTarget(null);
return;
}
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
const target = targetOf(e);
setDropTarget((prev) => (prev === target ? prev : target));
@@ -480,6 +510,10 @@ export function FileBrowser({
depth = 0;
setDropTarget(null);
if (handled || blocked(e)) return;
if (!allowed(e)) {
say(t('drop.readOnly'));
return;
}
const target = targetOf(e);
const snapshot = snapshotDrop(e.dataTransfer);
void collectDrop(snapshot).then((result) => {
@@ -675,20 +709,23 @@ export function FileBrowser({
onSelect: () => void startDownload({ kind: 'file', path: entry.path }),
});
}
actions.push(
{
// Nur, was die Buchstaben des Eintrags erlauben (N umbenennen, V verschieben, D loeschen).
if (canRename(entry.permissions)) {
actions.push({
id: 'rename',
label: t('menu.rename'),
icon: <PencilIcon />,
onSelect: () => setDialog({ kind: 'rename', entry }),
},
{
});
}
if (canMove(entry.permissions)) {
actions.push({
id: 'move',
label: t('menu.move'),
icon: <MoveIcon />,
onSelect: () => setDialog({ kind: 'move', entries: [entry] }),
},
);
});
}
// Nur Eintraege, die Nextcloud als teilbar kennzeichnet (Buchstabe R).
if (sharingEnabled && entry.permissions.includes('R')) {
actions.push({
@@ -707,14 +744,16 @@ export function FileBrowser({
separated: true,
});
}
actions.push({
id: 'delete',
label: t('menu.delete'),
icon: <TrashIcon />,
destructive: true,
separated: !(serverUrl && entry.fileId),
onSelect: () => setDialog({ kind: 'delete', entries: [entry] }),
});
if (canDelete(entry.permissions)) {
actions.push({
id: 'delete',
label: t('menu.delete'),
icon: <TrashIcon />,
destructive: true,
separated: !(serverUrl && entry.fileId),
onSelect: () => setDialog({ kind: 'delete', entries: [entry] }),
});
}
return actions;
};
@@ -827,7 +866,7 @@ export function FileBrowser({
return;
case 'Delete': {
const list = selectedEntries.length > 0 ? selectedEntries : entry ? [entry] : [];
if (list.length > 0) {
if (list.length > 0 && list.every((x) => canDelete(x.permissions))) {
e.preventDefault();
setDialog({ kind: 'delete', entries: list });
}
@@ -835,7 +874,7 @@ export function FileBrowser({
}
case 'F2': {
const target = selectedEntries.length === 1 ? selectedEntries[0] : entry;
if (target) {
if (target && canRename(target.permissions)) {
e.preventDefault();
setDialog({ kind: 'rename', entry: target });
}
@@ -938,17 +977,21 @@ export function FileBrowser({
aria-hidden="true"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground"
>
<UploadIcon size={20} />
{canUploadHere ? <UploadIcon size={20} /> : <FolderIcon size={20} />}
</span>
<p className="mt-4 text-base font-semibold text-foreground">{t('empty.title')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('empty.hint')}</p>
<button
type="button"
className="btn btn-secondary mt-5"
onClick={() => fileInput.current?.click()}
>
{t('toolbar.upload')}
</button>
<p className="mt-1 text-sm text-muted-foreground">
{canUploadHere ? t('empty.hint') : t('empty.readOnlyHint')}
</p>
{canUploadHere && (
<button
type="button"
className="btn btn-secondary mt-5"
onClick={() => fileInput.current?.click()}
>
{t('toolbar.upload')}
</button>
)}
</div>
</div>
);
@@ -987,6 +1030,8 @@ export function FileBrowser({
onMove={() => setDialog({ kind: 'move', entries: selectedEntries })}
onDelete={() => setDialog({ kind: 'delete', entries: selectedEntries })}
onClear={() => dispatch({ type: 'clear' })}
canMove={selectedEntries.every((e) => canMove(e.permissions))}
canDelete={selectedEntries.every((e) => canDelete(e.permissions))}
/>
) : (
<Toolbar
@@ -1000,6 +1045,8 @@ export function FileBrowser({
sort={sort}
onSortChange={setSort}
disabled={!listing}
canNewFolder={canNewFolderHere}
canUpload={canUploadHere}
/>
)}
</div>
@@ -9,8 +9,8 @@ import {
} from '@/components/nextcloud-files/file-format';
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 type { SortDir, SortKey } from './Toolbar';
import { TypeTile } from './TypeTile';
/** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */
@@ -9,9 +9,9 @@ import {
generatePassword,
type LinkFormValues,
linkPasswordMode,
policyToday,
type ShareAccessChoice,
type ShareTarget,
todayLocal,
} from '@/components/nextcloud-files/share-policy';
import type { NcShare, NcSharePolicy } from '@/lib/nextcloud-files-api';
@@ -56,11 +56,15 @@ export function LinkShareForm({
const editing = share !== undefined;
const options = accessOptions(target, 'link', policy);
const initialAccess: ShareAccessChoice =
share && options.includes(share.access as ShareAccessChoice)
? (share.access as ShareAccessChoice)
: 'view';
const [access, setAccess] = useState<ShareAccessChoice>(initialAccess);
// Gehoert die vorhandene Berechtigung nicht zu den angebotenen (z. B. Bearbeiten, obwohl die
// Nextcloud das oeffentliche Hochladen inzwischen verbietet, oder eine eigene Berechtigung),
// bleibt die Auswahl leer (`null`) und die Berechtigung unberuehrt: gesendet wird sie nur,
// wenn der Benutzer ausdruecklich eine andere waehlt (WR-01).
const currentNotOffered =
share !== undefined && !options.includes(share.access as ShareAccessChoice);
const [access, setAccess] = useState<ShareAccessChoice | null>(
share === undefined ? 'view' : currentNotOffered ? null : (share.access as ShareAccessChoice),
);
// --- Passwort ---
const passwordMode = linkPasswordMode(policy);
@@ -79,7 +83,8 @@ export function LinkShareForm({
const passwordFieldVisible = editing ? editPassword === 'change' : createPasswordOn;
// --- Ablauf ---
const today = todayLocal();
// Grenzen nach dem Datum des Servers, nicht der Uhr des Browsers (IN-01).
const today = policyToday(policy);
const rule = expiryRule(policy, today);
const [expiryOn, setExpiryOn] = useState(
editing
@@ -146,6 +151,22 @@ export function LinkShareForm({
>
<fieldset className="space-y-1.5">
<legend className="text-sm font-medium text-foreground">{t('accessLegend')}</legend>
{currentNotOffered && share && (
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name={`${ids}-access`}
value="current"
checked={access === null}
disabled
readOnly
className="mt-0.5 h-4 w-4"
/>
<span className="text-foreground">
{t('currentAccess', { level: tAccess(share.access) })}
</span>
</label>
)}
{options.map((o) => (
<label key={o} className="flex items-start gap-2 text-sm">
<input
@@ -13,12 +13,17 @@ export function SelectionBar({
onMove,
onDelete,
onClear,
canMove = true,
canDelete = true,
}: {
count: number;
onDownload: () => void;
onMove: () => void;
onDelete: () => void;
onClear: () => void;
/** Alle ausgewaehlten Eintraege duerfen verschoben / geloescht werden (Buchstaben V / D). */
canMove?: boolean;
canDelete?: boolean;
}) {
const t = useTranslations('nextcloudFiles.browser.selection');
return (
@@ -43,18 +48,22 @@ export function SelectionBar({
<DownloadIcon />
<span className="max-sm:sr-only">{t('download')}</span>
</button>
<button type="button" className="btn btn-subtle" onClick={onMove}>
<MoveIcon />
<span className="max-sm:sr-only">{t('move')}</span>
</button>
<button
type="button"
className="btn btn-subtle text-status-down-fg hover:text-status-down-fg"
onClick={onDelete}
>
<TrashIcon />
<span className="max-sm:sr-only">{t('delete')}</span>
</button>
{canMove && (
<button type="button" className="btn btn-subtle" onClick={onMove}>
<MoveIcon />
<span className="max-sm:sr-only">{t('move')}</span>
</button>
)}
{canDelete && (
<button
type="button"
className="btn btn-subtle text-status-down-fg hover:text-status-down-fg"
onClick={onDelete}
>
<TrashIcon />
<span className="max-sm:sr-only">{t('delete')}</span>
</button>
)}
</div>
);
}
@@ -703,3 +703,187 @@ describe('ShareDialog — Links', () => {
expect(screen.getAllByLabelText(/^Berechtigung für (?!die nächste)/)).toHaveLength(1);
});
});
// --- Nachbesserungen aus der Pruefung (quick-261009-dkv) ------------------------------------------
describe('ShareDialog — Link ändern mit nicht angebotener Berechtigung (WR-01)', () => {
const editLink = (over: Partial<NcShare>) => {
mockList.mockResolvedValue({
path: '/Projekte',
shares: [link({ label: 'Kunde', ...over })],
hidden: 0,
truncated: false,
});
mockUpdate.mockResolvedValue(link({ label: 'Angebot', ...over }));
};
// Die Nextcloud erlaubt oeffentliches Hochladen nicht mehr: angeboten wird nur "Ansehen".
const noUpload = () => mockPolicy.mockResolvedValue(policyWith({ uploadAllowed: false }));
it('Bearbeiten bleibt bestehen, wenn nur die Bezeichnung geaendert wird; die aktuelle Berechtigung steht gewaehlt und gesperrt da', async () => {
noUpload();
editLink({ access: 'edit', permissions: 15 });
mount();
await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' }));
const form = screen.getByRole('form', { name: 'Link ändern' });
const current = within(form).getByRole('radio', {
name: 'Aktuell: Bearbeiten',
}) as HTMLInputElement;
expect(current.checked).toBe(true);
expect(current.disabled).toBe(true);
expect((within(form).getByRole('radio', { name: /Ansehen/ }) as HTMLInputElement).checked).toBe(
false,
);
fireEvent.change(within(form).getByLabelText('Bezeichnung (freiwillig)'), {
target: { value: 'Angebot' },
});
fireEvent.click(within(form).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate).toHaveBeenCalledWith('31', { label: 'Angebot' });
});
it('eine eigene Berechtigung heisst "Aktuell: Eigene Berechtigung"; nur das Ablaufdatum aendern sendet keine Berechtigung', async () => {
editLink({ access: 'custom', permissions: 5, expiration: '2026-12-01' });
mount();
await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' }));
const form = screen.getByRole('form', { name: 'Link ändern' });
expect(within(form).getByRole('radio', { name: 'Aktuell: Eigene Berechtigung' })).toBeTruthy();
fireEvent.change(within(form).getByLabelText('Ablaufdatum'), {
target: { value: '2026-12-15' },
});
fireEvent.click(within(form).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate).toHaveBeenCalledWith('31', { expireDate: '2026-12-15' });
});
it('wer ausdruecklich eine andere Berechtigung waehlt, sendet sie', async () => {
noUpload();
editLink({ access: 'edit', permissions: 15 });
mount();
await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' }));
const form = screen.getByRole('form', { name: 'Link ändern' });
fireEvent.click(within(form).getByRole('radio', { name: /Ansehen/ }));
fireEvent.click(within(form).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('31', { access: 'view' }));
});
it('eine angebotene Berechtigung ist normal gewaehlt und es gibt keine Zeile "Aktuell"', async () => {
editLink({ access: 'view', permissions: 1 });
mount();
await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' }));
const form = screen.getByRole('form', { name: 'Link ändern' });
expect((within(form).getByRole('radio', { name: /Ansehen/ }) as HTMLInputElement).checked).toBe(
true,
);
expect(within(form).queryByRole('radio', { name: /Aktuell/ })).toBeNull();
});
});
describe('ShareDialog — Fehler gehoeren zu ihrem Abschnitt (IN-02)', () => {
const section = (name: string) => screen.getByRole('region', { name });
it('ein Fehler der Suche steht unter "Personen und Gruppen", nicht unter "Link"', async () => {
mockSearch.mockRejectedValue(
new NextcloudFilesRequestError(502, 'nextcloudError', 'Nextcloud antwortet nicht richtig.'),
);
mount();
type(await ready(), 'be');
const alert = await screen.findByRole('alert');
expect(within(section('Personen und Gruppen')).getByRole('alert')).toBe(alert);
expect(within(section('Link')).queryByRole('alert')).toBeNull();
});
it('ein Linkfehler bleibt stehen, wenn man im Suchfeld tippt; ein Suchfehler verschwindet', async () => {
mockList.mockResolvedValue({
path: '/Projekte',
shares: [link({ label: 'Kunde' })],
hidden: 0,
truncated: false,
});
mockUpdate.mockRejectedValue(
new NextcloudFilesRequestError(
422,
'shareRejected',
'Nextcloud hat diese Freigabe abgelehnt.',
),
);
mount();
const input = await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' }));
const form = screen.getByRole('form', { name: 'Link ändern' });
fireEvent.change(within(form).getByLabelText('Bezeichnung (freiwillig)'), {
target: { value: 'Angebot' },
});
fireEvent.click(within(form).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(within(section('Link')).getByRole('alert')).toBeTruthy());
type(input, 'b');
await waitFor(() => expect(mockSearch).toHaveBeenCalled());
expect(within(section('Link')).getByRole('alert')).toBeTruthy();
});
it('ein Suchfehler wird beim naechsten Tippen geloescht', async () => {
mockSearch.mockRejectedValueOnce(
new NextcloudFilesRequestError(502, 'nextcloudError', 'Nextcloud antwortet nicht richtig.'),
);
mount();
const input = await ready();
type(input, 'be');
await screen.findByRole('alert');
type(input, 'ben');
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
});
});
describe('ShareDialog — Kleinigkeiten (IN-05, IN-01)', () => {
it('"Ändern" am Link gibt es nur, wenn die Freigabe aenderbar ist', async () => {
mockList.mockResolvedValue({
path: '/Projekte',
shares: [link({ label: 'Fest', canEdit: false }), link({ id: '32', label: 'Frei' })],
hidden: 0,
truncated: false,
});
mount();
await ready();
expect(screen.queryByRole('button', { name: 'Link „Fest“ ändern' })).toBeNull();
expect(screen.getByRole('button', { name: 'Link „Frei“ ändern' })).toBeTruthy();
});
it('"Kopiert" verschwindet nach kurzer Zeit wieder', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
mockList.mockResolvedValue({
path: '/Projekte',
shares: [link()],
hidden: 0,
truncated: false,
});
mount();
await ready();
fireEvent.click(screen.getByRole('button', { name: 'Link kopieren' }));
expect(await screen.findByRole('button', { name: 'Kopiert' })).toBeTruthy();
await waitFor(
() => expect(screen.getByRole('button', { name: 'Link kopieren' })).toBeTruthy(),
{
timeout: 3500,
},
);
}, 8000);
it('Ablaufgrenzen rechnen mit dem Serverdatum der Regeln, nicht mit der Uhr des Browsers', async () => {
mockPolicy.mockResolvedValue({
...policyWith({ expiryEnforced: true, expiryDefaultDays: 7 }),
today: '2031-03-02',
});
const form = await openCreate();
const date = within(form).getByLabelText(
'Ablaufdatum (Ihre Nextcloud verlangt eines)',
) as HTMLInputElement;
expect(date.value).toBe('2031-03-09');
expect(date.max).toBe('2031-03-09');
expect(date.min).toBe('2031-03-02');
});
});
@@ -2,13 +2,13 @@
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { copyText } from '@/components/nextcloud-files/clipboard';
import {
type ErrorLike,
errorText,
ncMessageOf,
toErrorLike,
} from '@/components/nextcloud-files/error-text';
import { copyText } from '@/components/nextcloud-files/clipboard';
import {
accessOptions,
type LinkFormValues,
@@ -32,6 +32,7 @@ import { CloseIcon, CopyIcon, LinkIcon, UserIcon, UsersIcon } from './icons';
import { LinkShareForm } from './LinkShareForm';
const SEARCH_DEBOUNCE_MS = 300;
const COPIED_RESET_MS = 2000;
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';
@@ -102,12 +103,26 @@ export function ShareDialog({
const onExpiredRef = useRef(onExpired);
onExpiredRef.current = onExpired;
const fail = (err: unknown) => {
// „Kopiert“ verschwindet nach kurzer Zeit wieder, damit der Knopf erneut „Kopieren“ sagt.
useEffect(() => {
if (copiedId === null) return;
const timer = window.setTimeout(() => setCopiedId(null), COPIED_RESET_MS);
return () => window.clearTimeout(timer);
}, [copiedId]);
useEffect(() => {
if (!revealedCopied) return;
const timer = window.setTimeout(() => setRevealedCopied(false), COPIED_RESET_MS);
return () => window.clearTimeout(timer);
}, [revealedCopied]);
// `section` ordnet den Fehler dem Abschnitt zu, in dem er entstand (Suche = Personen).
const fail = (err: unknown, section?: Section) => {
const e = toErrorLike(err);
if (e.code === 'connectionExpired') {
onExpiredRef.current();
return;
}
if (section) setErrorSection(section);
setError(e);
};
const failRef = useRef(fail);
@@ -167,7 +182,7 @@ export function ShareDialog({
.catch((err) => {
if (seq !== searchSeq.current) return;
setResults([]);
failRef.current(err);
failRef.current(err, 'people');
})
.finally(() => {
if (seq === searchSeq.current) setSearching(false);
@@ -194,7 +209,7 @@ export function ShareDialog({
try {
await action();
} catch (err) {
fail(err);
fail(err, section);
} finally {
setBusy(false);
}
@@ -242,7 +257,7 @@ export function ShareDialog({
const created = await createShare({
path: target.path,
kind: 'link',
access: values.access,
access: values.access ?? 'view',
...(values.password ? { password: values.password } : {}),
// Immer senden (Datum oder leer), damit die Nextcloud keinen eigenen Ablauf einsetzt.
expireDate: values.expireDate,
@@ -369,7 +384,8 @@ export function ShareDialog({
placeholder={t('people.searchPlaceholder')}
onChange={(e) => {
setTerm(e.target.value);
setError(null);
// Nur den Fehler der Personen/Suche loeschen; ein Linkfehler bleibt stehen.
setError((prev) => (errorSection === 'people' ? null : prev));
}}
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"
@@ -564,18 +580,22 @@ export function ShareDialog({
</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-label={t('link.editAria', { name })}
onClick={() => {
setConfirmId(null);
setLinkPanel(editingThis ? null : { mode: 'edit', id: share.id });
}}
>
{t('link.edit')}
</button>
{share.canEdit && (
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-label={t('link.editAria', { name })}
onClick={() => {
setConfirmId(null);
setLinkPanel(
editingThis ? null : { mode: 'edit', id: share.id },
);
}}
>
{t('link.edit')}
</button>
)}
<button
type="button"
className="btn btn-subtle"
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, 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 NcShare, NextcloudFilesRequestError } from '@/lib/nextcloud-files-api';
@@ -344,3 +344,114 @@ describe('SharesView — Mit mir geteilt', () => {
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
});
});
describe('SharesView — Nachbesserungen aus der Prüfung', () => {
const wrap = (mode: 'byMe' | 'withMe', expired: () => void = onExpired) => (
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<SharesView mode={mode} onOpen={onOpen} onExpired={expired} />
</NextIntlClientProvider>
);
it('ein bei jedem Rendern neuer onExpired-Rueckruf laedt nicht erneut (WR-04)', async () => {
const view = render(wrap('byMe', () => undefined));
await screen.findByText(/Sie haben noch nichts geteilt/);
expect(mockMine).toHaveBeenCalledTimes(1);
for (let i = 0; i < 3; i++) view.rerender(wrap('byMe', () => undefined));
await act(async () => {});
expect(mockMine).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('status')).toBeNull();
});
it('der aktuelle Rueckruf wird trotzdem verwendet, wenn die Verbindung ablaeuft', async () => {
const first = vi.fn();
const second = vi.fn();
mockMine.mockRejectedValueOnce(new NextcloudFilesRequestError(409, 'connectionExpired', 'x'));
const view = render(wrap('byMe', first));
view.rerender(wrap('byMe', second));
await waitFor(() => expect(first.mock.calls.length + second.mock.calls.length).toBe(1));
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();
});
it('ueberlappende Ladevorgaenge: ein spaeter eintreffendes, aelteres Ergebnis wird verworfen (WR-04)', async () => {
let resolveMine: (v: unknown) => void = () => undefined;
mockMine.mockReturnValue(
new Promise((resolve) => {
resolveMine = resolve;
}),
);
mockReceived.mockResolvedValue({
shares: [incoming()],
pending: [],
hidden: 0,
truncated: false,
});
const view = render(wrap('byMe'));
view.rerender(wrap('withMe'));
expect(await screen.findByText('Ben-Ordner')).toBeTruthy();
await act(async () => {
resolveMine({ shares: [share()], hidden: 0, truncated: false });
});
expect(screen.queryByText('Projekte')).toBeNull();
expect(screen.getByText('Ben-Ordner')).toBeTruthy();
expect(screen.queryByRole('status')).toBeNull();
});
it('nach dem Verlassen der Ansicht aendert ein spaetes Ergebnis nichts mehr', async () => {
let resolveMine: (v: unknown) => void = () => undefined;
mockMine.mockReturnValue(
new Promise((resolve) => {
resolveMine = resolve;
}),
);
const view = render(wrap('byMe'));
view.unmount();
await act(async () => {
resolveMine({ shares: [share()], hidden: 0, truncated: false });
});
expect(screen.queryByText('Projekte')).toBeNull();
});
it('Weitergabe: die Zeile nennt den Eigentuemer der Datei', async () => {
mockMine.mockResolvedValue({
shares: [share({ fileOwnerName: 'Anna Müller', ownerId: 'ben', path: '/rs-test/Sub' })],
hidden: 0,
truncated: false,
});
mount('byMe');
expect(
await screen.findByText('Von Ihnen weitergegeben, Eigentümer: Anna Müller'),
).toBeTruthy();
});
it('Namen mit doppeltem Leerzeichen: "Im Ordner zeigen" und "Öffnen" nennen den Eintrag woertlich', async () => {
mockMine.mockResolvedValue({
shares: [
share({ path: '/Kunden/Müller GmbH.pdf', name: 'Müller GmbH.pdf', itemType: 'file' }),
],
hidden: 0,
truncated: false,
});
mount('byMe');
fireEvent.click(await screen.findByRole('button', { name: /im Ordner zeigen/ }));
expect(onOpen).toHaveBeenLastCalledWith('/Kunden', 'Müller GmbH.pdf');
cleanup();
mockReceived.mockResolvedValue({
shares: [
incoming({
itemType: 'file',
path: '/Q/Müller GmbH.pdf',
target: '/Ben/Müller GmbH.pdf',
name: 'Müller GmbH.pdf',
}),
],
pending: [],
hidden: 0,
truncated: false,
});
mount('withMe');
fireEvent.click(await screen.findByRole('button', { name: /öffnen/ }));
expect(onOpen).toHaveBeenLastCalledWith('/Ben', 'Müller GmbH.pdf');
});
});
@@ -1,7 +1,7 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type ErrorLike,
errorText,
@@ -31,6 +31,11 @@ function parentOf(path: string): string {
return i <= 0 ? '/' : path.slice(0, i);
}
/** Letzter Teil eines Pfads, woertlich (er dient dem Finden des Eintrags, nie der Anzeige). */
function baseName(path: string): string {
return path.slice(path.lastIndexOf('/') + 1);
}
function shareTarget(share: NcShare): ShareTarget {
return {
path: share.path,
@@ -72,30 +77,44 @@ export function SharesView({
const [managing, setManaging] = useState<ShareTarget | null>(null);
const [leaving, setLeaving] = useState<string | null>(null);
const fail = useCallback(
(err: unknown) => {
const e = toErrorLike(err);
if (e.code === 'connectionExpired') {
onExpired();
return;
}
setError(e);
// Der Rueckruf kommt vom Elternteil und kann bei jedem Rendern neu sein; er darf das Laden
// nie neu ausloesen (sonst blitzt die Liste auf). Darum ueber einen Verweis aufrufen (WR-04).
const onExpiredRef = useRef(onExpired);
onExpiredRef.current = onExpired;
/** Nummer des juengsten Ladevorgangs: nur dessen Ergebnis wird angewendet. */
const loadSeq = useRef(0);
// Beim Verlassen laufende Ladevorgaenge entwerten.
useEffect(
() => () => {
loadSeq.current += 1;
},
[onExpired],
[],
);
const fail = useCallback((err: unknown) => {
const e = toErrorLike(err);
if (e.code === 'connectionExpired') {
onExpiredRef.current();
return;
}
setError(e);
}, []);
const load = useCallback(
async (quiet = false) => {
const seq = ++loadSeq.current;
if (!quiet) setLoading(true);
try {
if (mode === 'byMe') {
const out = await listMyShares();
if (seq !== loadSeq.current) return;
setShares(out.shares);
setPending([]);
setHidden(out.hidden);
setTruncated(out.truncated);
} else {
const out = await listReceivedShares();
if (seq !== loadSeq.current) return;
setShares(out.shares);
setPending(out.pending);
setHidden(out.hidden);
@@ -103,9 +122,10 @@ export function SharesView({
}
setError(null);
} catch (err) {
if (seq !== loadSeq.current) return;
fail(err);
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
},
[mode, fail],
@@ -241,7 +261,9 @@ export function SharesView({
disabled={busy}
aria-label={t('openAria', { name: share.name })}
onClick={() =>
isFolder ? onOpen(share.target) : onOpen(parentOf(share.target), share.name)
isFolder
? onOpen(share.target)
: onOpen(parentOf(share.target), baseName(share.target))
}
>
{t('open')}
@@ -334,6 +356,11 @@ export function SharesView({
<p className="text-xs text-muted-foreground">
{t('sharedWith', { list: group.map(recipientText).join(', ') })}
</p>
{first.fileOwnerName && (
<p className="text-xs text-muted-foreground">
{t('reshared', { owner: first.fileOwnerName })}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-1">
<button
@@ -350,7 +377,7 @@ export function SharesView({
className="btn btn-subtle"
disabled={busy}
aria-label={t('showInFolderAria', { name: first.name })}
onClick={() => onOpen(folder, first.name)}
onClick={() => onOpen(folder, baseName(first.path))}
>
{t('showInFolder')}
</button>
@@ -30,6 +30,8 @@ export function Toolbar({
sort,
onSortChange,
disabled = false,
canNewFolder = true,
canUpload = true,
}: {
path: string;
onNavigate: (path: string) => void;
@@ -41,6 +43,9 @@ export function Toolbar({
sort: { key: SortKey; dir: SortDir };
onSortChange: (sort: { key: SortKey; dir: SortDir }) => void;
disabled?: boolean;
/** Der Ordner erlaubt neue Ordner / Dateien (Berechtigungsbuchstaben K / C); sonst ausgeblendet. */
canNewFolder?: boolean;
canUpload?: boolean;
}) {
const t = useTranslations('nextcloudFiles.browser');
@@ -113,25 +118,32 @@ export function Toolbar({
<GridIcon size={15} />
</button>
</fieldset>
<button
type="button"
className="btn btn-secondary"
onClick={onNewFolder}
disabled={disabled}
aria-label={t('toolbar.newFolder')}
>
<FolderPlusIcon />
<span className="hidden md:inline">{t('toolbar.newFolder')}</span>
</button>
<button
type="button"
className="btn btn-primary"
onClick={onUploadClick}
disabled={disabled}
>
<UploadIcon />
{t('toolbar.upload')}
</button>
{!canNewFolder && !canUpload && (
<span className="px-1 text-sm text-muted-foreground">{t('toolbar.readOnly')}</span>
)}
{canNewFolder && (
<button
type="button"
className="btn btn-secondary"
onClick={onNewFolder}
disabled={disabled}
aria-label={t('toolbar.newFolder')}
>
<FolderPlusIcon />
<span className="hidden md:inline">{t('toolbar.newFolder')}</span>
</button>
)}
{canUpload && (
<button
type="button"
className="btn btn-primary"
onClick={onUploadClick}
disabled={disabled}
>
<UploadIcon />
{t('toolbar.upload')}
</button>
)}
</div>
</div>
);
@@ -712,6 +712,27 @@ describe('Reiter fuer Freigaben (quick-261009-dkv)', () => {
expect(mockListFolder).not.toHaveBeenCalledWith('/Ben-Ordner');
});
it('ein Neuzeichnen der Seite laedt die offene Freigabe-Ansicht nicht neu (WR-04)', async () => {
let resolveInfo: (info: NextcloudServerInfo) => void = () => undefined;
mockGetServerInfo.mockReturnValue(
new Promise<NextcloudServerInfo>((resolve) => {
resolveInfo = resolve;
}),
);
mockGetStatus.mockResolvedValue(connectedStatus());
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Von mir geteilt' }));
expect(await screen.findByText(/Sie haben noch nichts geteilt/)).toBeTruthy();
expect(mockListMine).toHaveBeenCalledTimes(1);
// Die Kennung der Nextcloud trifft spaet ein: die Seite zeichnet sich neu.
await act(async () => {
resolveInfo(serverInfo());
});
await act(async () => {});
expect(mockListMine).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Freigaben werden geladen …')).toBeNull();
});
it('eine Datei oeffnet den Ordner darueber', async () => {
mockGetStatus.mockResolvedValue(connectedStatus());
mockListReceived.mockResolvedValue({
@@ -135,7 +135,8 @@ export default function NextcloudFilesPage() {
setBrowserStart({ path, focus });
setTab('files');
};
const refreshAfterExpiry = () => void reloadStatus();
// Stabil, damit die Freigabe-Ansichten nicht bei jedem Rendern der Seite neu laden (WR-04).
const refreshAfterExpiry = useCallback(() => void reloadStatus(), [reloadStatus]);
return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">