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">
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { canCreateFolderIn, canDelete, canMove, canRename, canUploadTo } from './entry-permissions';
describe('Berechtigungsbuchstaben (gemessen an Nextcloud 34)', () => {
it('eigener Ordner RGDNVCK: alles erlaubt', () => {
expect(canUploadTo('RGDNVCK')).toBe(true);
expect(canCreateFolderIn('RGDNVCK')).toBe(true);
expect(canRename('RGDNVCK')).toBe(true);
expect(canMove('RGDNVCK')).toBe(true);
expect(canDelete('RGDNVCK')).toBe(true);
});
it('zum Ansehen geteilter Ordner selbst (SGDN): nichts anlegen', () => {
expect(canUploadTo('SGDN')).toBe(false);
expect(canCreateFolderIn('SGDN')).toBe(false);
});
it('Inhalt eines zum Ansehen geteilten Ordners (SG): weder anlegen, umbenennen, verschieben noch loeschen', () => {
for (const fn of [canUploadTo, canCreateFolderIn, canRename, canMove, canDelete]) {
expect(fn('SG')).toBe(false);
}
});
it('zum Bearbeiten geteilter Ordner (SRGDNVCK): alles erlaubt', () => {
expect(canUploadTo('SRGDNVCK')).toBe(true);
expect(canCreateFolderIn('SRGDNVCK')).toBe(true);
expect(canDelete('SRGDNVCK')).toBe(true);
});
it('Einzelbuchstaben zaehlen einzeln', () => {
expect(canUploadTo('RGC')).toBe(true);
expect(canCreateFolderIn('RGC')).toBe(false);
expect(canCreateFolderIn('RGK')).toBe(true);
expect(canUploadTo('RGK')).toBe(false);
expect(canRename('RGN')).toBe(true);
expect(canMove('RGN')).toBe(false);
});
it('unbekannt (null, undefined, leer) ist nie verboten', () => {
for (const letters of [null, undefined, '']) {
for (const fn of [canUploadTo, canCreateFolderIn, canRename, canMove, canDelete]) {
expect(fn(letters)).toBe(true);
}
}
});
});
@@ -0,0 +1,40 @@
/**
* Was die Berechtigungsbuchstaben der Nextcloud (`oc:permissions`) erlauben. Der Browser blendet
* damit Aktionen aus, die die Nextcloud ohnehin ablehnen wuerde (z. B. "Neuer Ordner" in einem
* Ordner, der nur zum Ansehen geteilt wurde); verbindlich entscheidet weiter die Nextcloud.
*
* Gemessen (Nextcloud 34): eigener Ordner `RGDNVCK`, eigene Datei `RGDNVW`, ein zum Ansehen
* geteilter Ordner selbst geoeffnet `SGDN`, sein Inhalt `SG`, ein zum Bearbeiten geteilter Ordner
* `SRGDNVCK`. Buchstaben: C Dateien anlegen, K Ordner anlegen, D loeschen, N umbenennen,
* V verschieben, W Datei aendern.
*
* Fehlen die Buchstaben ganz (`null`, `undefined`, leer), ist das "unbekannt" und nie "verboten":
* es wird nichts ausgeblendet, die Nextcloud entscheidet.
*/
export type Letters = string | null | undefined;
const known = (letters: Letters): letters is string =>
typeof letters === 'string' && letters !== '';
/** In diesem Ordner duerfen Dateien angelegt werden (Hochladen, Ablegen). */
export function canUploadTo(letters: Letters): boolean {
return !known(letters) || letters.includes('C');
}
/** In diesem Ordner duerfen Ordner angelegt werden. */
export function canCreateFolderIn(letters: Letters): boolean {
return !known(letters) || letters.includes('K');
}
export function canRename(letters: Letters): boolean {
return !known(letters) || letters.includes('N');
}
export function canMove(letters: Letters): boolean {
return !known(letters) || letters.includes('V');
}
export function canDelete(letters: Letters): boolean {
return !known(letters) || letters.includes('D');
}
@@ -7,6 +7,7 @@ import {
generatePassword,
linkPasswordMode,
linkUpdateDiff,
policyToday,
type RandomFill,
type ShareTarget,
targetFromEntry,
@@ -88,11 +89,14 @@ function counter(start = 0): RandomFill {
}
describe('generatePassword', () => {
it('mindestens 20 Zeichen, von der Mindestlaenge der Nextcloud gehoben, bei 64 gedeckelt', () => {
it('mindestens 20 Zeichen, von der Mindestlaenge der Nextcloud gehoben, nie darunter (bis 256)', () => {
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);
// eine hohe Mindestlaenge der Nextcloud wird erreicht, sonst wuerde sie das Passwort ablehnen (IN-04)
expect(generatePassword(100, counter(5))).toHaveLength(100);
expect(generatePassword(256, counter(6))).toHaveLength(256);
expect(generatePassword(500, counter(4))).toHaveLength(256);
});
it('enthaelt Gross-, Kleinbuchstaben, Ziffer und Sonderzeichen, nie aehnliche Zeichen', () => {
@@ -134,6 +138,15 @@ describe('Datumshilfen', () => {
expect(addDays('2026-10-09', 30)).toBe('2026-11-08');
});
it('policyToday nimmt das Serverdatum der Regeln, sonst die Uhr des Browsers (IN-01)', () => {
const browser = new Date(2026, 0, 5, 12, 0);
expect(policyToday({ ...POLICY, today: '2026-10-09' }, browser)).toBe('2026-10-09');
expect(policyToday(POLICY, browser)).toBe('2026-01-05');
expect(policyToday(null, browser)).toBe('2026-01-05');
// Unsinn vom Server wird nicht uebernommen
expect(policyToday({ ...POLICY, today: '9.10.2026' }, browser)).toBe('2026-01-05');
});
it('todayLocal nimmt den lokalen Kalendertag, nicht UTC', () => {
expect(todayLocal(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05');
expect(todayLocal(new Date(2026, 11, 31, 0, 1))).toBe('2026-12-31');
@@ -215,6 +228,35 @@ describe('linkUpdateDiff', () => {
).toEqual({ password: '' });
});
it('Berechtigung nicht angefasst (null): sie wird nie gesendet, auch nicht bei eigener Berechtigung (WR-01)', () => {
const custom = { ...share, access: 'custom' } as NcShare;
expect(
linkUpdateDiff(custom, {
access: null,
password: null,
expireDate: '2026-12-01',
label: 'Neu',
}),
).toEqual({ label: 'Neu' });
expect(
linkUpdateDiff(custom, {
access: null,
password: null,
expireDate: '2026-12-01',
label: 'Kunde',
}),
).toEqual({});
// ausdruecklich gewaehlt wird sie gesendet
expect(
linkUpdateDiff(custom, {
access: 'view',
password: null,
expireDate: '2026-12-01',
label: 'Kunde',
}),
).toEqual({ access: 'view' });
});
it('ein Link ohne Ablauf vergleicht mit der leeren Zeichenkette', () => {
const open = { ...share, expiration: null } as NcShare;
expect(
@@ -66,11 +66,21 @@ export function linkPasswordMode(policy: NcSharePolicy | null): LinkPasswordMode
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
/** Heutiges Datum nach der Uhr des Benutzers als `YYYY-MM-DD` (nicht UTC). */
/** Heutiges Datum nach der Uhr des Browsers als `YYYY-MM-DD` (nicht UTC). Nur Rueckfall, siehe `policyToday`. */
export function todayLocal(now: Date = new Date()): string {
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
/**
* Das Datum, nach dem Ablaufgrenzen gerechnet werden: das Serverdatum aus den Regeln (die
* Nextcloud rechnet nach der Uhr des Servers, nicht der des Browsers). Fehlt es, der Rueckfall
* auf die Uhr des Browsers.
*/
export function policyToday(policy: NcSharePolicy | null, now: Date = new Date()): string {
const today = policy?.today;
return typeof today === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(today) ? today : todayLocal(now);
}
/** Reine Kalenderrechnung (UTC, keine Sommerzeit-Fallen): `date` plus `n` Tage. */
export function addDays(date: string, n: number): string {
const [y, m, d] = date.split('-').map(Number);
@@ -101,9 +111,13 @@ export function expiryRule(policy: NcSharePolicy | null, today: string): ExpiryR
};
}
/** Eingaben des Link-Formulars beim Aendern. `password`: null = behalten, '' = entfernen. */
/**
* Eingaben des Link-Formulars beim Aendern. `password`: null = behalten, '' = entfernen.
* `access`: null = nicht angefasst (die vorhandene Berechtigung bleibt, auch wenn sie nicht zu
* den angebotenen gehoert); beim Anlegen steht immer eine Auswahl da.
*/
export interface LinkFormValues {
access: ShareAccessChoice;
access: ShareAccessChoice | null;
password: string | null;
expireDate: string;
label: string;
@@ -112,7 +126,7 @@ export interface LinkFormValues {
/** Nur die Felder, die sich gegenueber der vorhandenen Freigabe geaendert haben. */
export function linkUpdateDiff(share: NcShare, form: LinkFormValues): UpdateShareInput {
const diff: UpdateShareInput = {};
if (form.access !== share.access) diff.access = form.access;
if (form.access !== null && form.access !== share.access) diff.access = form.access;
if (form.password !== null) diff.password = form.password;
if (form.expireDate !== (share.expiration ?? '')) diff.expireDate = form.expireDate;
if (form.label !== share.label) diff.label = form.label;
@@ -127,7 +141,7 @@ const LOWER = 'abcdefghijkmnpqrstuvwxyz';
const DIGITS = '23456789';
const SPECIAL = '!#$%&*+-=?@_';
const MIN_GENERATED = 20;
const MAX_GENERATED = 64;
const MAX_GENERATED = 256;
export type RandomFill = (array: Uint32Array) => Uint32Array;
@@ -149,7 +163,8 @@ function pick(chars: string, fill: RandomFill): string {
/**
* Zufallspasswort aus der Betriebssystem-Zufallsquelle: mindestens 20 Zeichen (oder die
* Mindestlaenge der Nextcloud, hoechstens 64), je mindestens ein Gross- und Kleinbuchstabe,
* Mindestlaenge der Nextcloud, die bis 256 reicht und nie unterschritten wird; sonst waere
* das Passwort fuer die Nextcloud zu kurz), 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 {
+9
View File
@@ -225,6 +225,11 @@ export interface NcListing {
quota: NcQuota;
/** true, wenn der Ordner mehr als 5000 Eintraege hat und abgeschnitten wurde. */
truncated: boolean;
/**
* Berechtigungsbuchstaben des Ordners selbst (z. B. `RGDNVCK`; C Dateien anlegen, K Ordner
* anlegen). `null`/fehlend = unbekannt: dann wird nichts ausgeblendet, die Nextcloud entscheidet.
*/
permissions?: string | null;
}
/** Ordnerinhalt samt Speicherangaben. Der Pfad geht nur in der Query, nie im URL-Pfad. */
@@ -338,6 +343,8 @@ export interface NcShare {
shareWithName: string | null;
ownerId: string | null;
ownerName: string | null;
/** Eigentuemer der Datei, nur bei Weitergaben (der Benutzer gibt etwas weiter, das ihm geteilt wurde). */
fileOwnerName?: string | null;
canEdit: boolean;
canDelete: boolean;
/** `YYYY-MM-DD` oder null. */
@@ -360,6 +367,8 @@ export interface NcSharee {
/** Freigaberegeln der Nextcloud fuer den angemeldeten Benutzer (frisch gelesen). */
export interface NcSharePolicy {
/** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Servers: Grundlage fuer Ablaufgrenzen. */
today?: string;
enabled: boolean;
groupsEnabled: boolean;
links: {
+10 -5
View File
@@ -2444,7 +2444,8 @@
"sortBy": "Sortieren nach",
"sortAscending": "Aufsteigend",
"sortDescending": "Absteigend",
"reload": "Neu laden"
"reload": "Neu laden",
"readOnly": "Nur ansehen"
},
"sort": {
"name": "Name",
@@ -2479,7 +2480,8 @@
},
"empty": {
"title": "Dieser Ordner ist leer.",
"hint": "Dateien hierher ziehen oder hochladen."
"hint": "Dateien hierher ziehen oder hochladen.",
"readOnlyHint": "Hier lässt sich nichts hinzufügen."
},
"loading": "Ordner wird geladen …",
"folderSize": "Ordner",
@@ -2500,7 +2502,8 @@
"hint": "Ordner werden mit ihrem Inhalt übernommen.",
"tooMany": "Zu viele Dateien auf einmal. Legen Sie höchstens 2000 Dateien in einem Schritt ab.",
"tooDeep": "Die Ordner sind zu tief verschachtelt. Mehr als 20 Ebenen lassen sich nicht ablegen.",
"unreadable": "Die abgelegten Dateien ließen sich nicht lesen. Bitte versuchen Sie es erneut."
"unreadable": "Die abgelegten Dateien ließen sich nicht lesen. Bitte versuchen Sie es erneut.",
"readOnly": "In diesen Ordner dürfen Sie nichts ablegen."
},
"status": {
"deleted": "{count, plural, one {„{name}“ ist im Papierkorb.} other {# Elemente in den Papierkorb verschoben.}}",
@@ -2663,7 +2666,8 @@
"labelHint": "Hilft Ihnen, mehrere Links auseinanderzuhalten.",
"cancel": "Abbrechen",
"submitCreate": "Link erstellen",
"submitSave": "Speichern"
"submitSave": "Speichern",
"currentAccess": "Aktuell: {level}"
}
},
"codes": {
@@ -2728,7 +2732,8 @@
"decline": "Ablehnen",
"declineAria": "„{name}“ ablehnen",
"truncated": "Es werden nicht alle Freigaben angezeigt. Den Rest sehen Sie in Nextcloud.",
"reload": "Neu laden"
"reload": "Neu laden",
"reshared": "Von Ihnen weitergegeben, Eigentümer: {owner}"
}
}
}
+10 -5
View File
@@ -2444,7 +2444,8 @@
"sortBy": "Sort by",
"sortAscending": "Ascending",
"sortDescending": "Descending",
"reload": "Reload"
"reload": "Reload",
"readOnly": "View only"
},
"sort": {
"name": "Name",
@@ -2479,7 +2480,8 @@
},
"empty": {
"title": "This folder is empty.",
"hint": "Drag files here or upload them."
"hint": "Drag files here or upload them.",
"readOnlyHint": "Nothing can be added here."
},
"loading": "Loading folder …",
"folderSize": "Folder",
@@ -2500,7 +2502,8 @@
"hint": "Folders are uploaded with their contents.",
"tooMany": "Too many files at once. Drop at most 2000 files in one go.",
"tooDeep": "The folders are nested too deeply. More than 20 levels cannot be dropped.",
"unreadable": "The dropped files could not be read. Please try again."
"unreadable": "The dropped files could not be read. Please try again.",
"readOnly": "You may not drop files into this folder."
},
"status": {
"deleted": "{count, plural, one {“{name}” is in the trash.} other {# items moved to the trash.}}",
@@ -2663,7 +2666,8 @@
"labelHint": "Helps you tell several links apart.",
"cancel": "Cancel",
"submitCreate": "Create link",
"submitSave": "Save"
"submitSave": "Save",
"currentAccess": "Current: {level}"
}
},
"codes": {
@@ -2728,7 +2732,8 @@
"decline": "Decline",
"declineAria": "Decline “{name}”",
"truncated": "Not all shares are shown. You see the rest in Nextcloud.",
"reload": "Reload"
"reload": "Reload",
"reshared": "Passed on by you, owner: {owner}"
}
}
}