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:
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user