feat(quick-261009-dkv): Teilen mit Personen und Gruppen - Durchstich, Oberflaeche
- Teilen-Dialog (Suche mit Verzoegerung, Ergebnisliste als Combobox, Berechtigung je Zeile aendern, entfernen) auf dem Modul-Dialog, Fehler mit Meldung der Nextcloud - Menueeintrag Teilen (nur bei Buchstabe R), Freigabe-Kennzeichen in Liste und Raster (ausgehend als Knopf, eingehend als Markierung) - Web-Client, reine Hilfen (Berechtigungsauswahl, Passwort erzeugen), Fehlertexte und Meldungen de/en; sharingDisabled traegt scope=groups fuer den Gruppentext Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,7 @@ describe('NextcloudFilesSharesService — create', () => {
|
|||||||
expect(codeOf(e)).toBe('sharingDisabled');
|
expect(codeOf(e)).toBe('sharingDisabled');
|
||||||
expect(statusOf(e)).toBe(409);
|
expect(statusOf(e)).toBe(409);
|
||||||
expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.');
|
expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.');
|
||||||
|
expect(bodyOf(e).scope).toBe('groups');
|
||||||
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export class NextcloudFilesSharesService {
|
|||||||
const policy = await this.loadPolicy(tenantId, userId, session);
|
const policy = await this.loadPolicy(tenantId, userId, session);
|
||||||
if (!policy.enabled) throw ncErrorDefault('sharingDisabled');
|
if (!policy.enabled) throw ncErrorDefault('sharingDisabled');
|
||||||
if (input.kind === 'group' && !policy.groupsEnabled) {
|
if (input.kind === 'group' && !policy.groupsEnabled) {
|
||||||
throw ncErrorDefault('sharingDisabled', undefined, GROUPS_OFF);
|
throw ncErrorDefault('sharingDisabled', { scope: 'groups' }, GROUPS_OFF);
|
||||||
}
|
}
|
||||||
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
|
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
|
||||||
if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) {
|
if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const mockDelete = vi.fn();
|
|||||||
const mockUpload = vi.fn();
|
const mockUpload = vi.fn();
|
||||||
const mockCheckDownload = vi.fn();
|
const mockCheckDownload = vi.fn();
|
||||||
const mockCheckZip = vi.fn();
|
const mockCheckZip = vi.fn();
|
||||||
|
const mockPolicy = vi.fn();
|
||||||
|
const mockSharesForPath = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||||
@@ -38,6 +40,8 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
|||||||
deleteEntry: (...a: unknown[]) => mockDelete(...a),
|
deleteEntry: (...a: unknown[]) => mockDelete(...a),
|
||||||
checkDownload: (...a: unknown[]) => mockCheckDownload(...a),
|
checkDownload: (...a: unknown[]) => mockCheckDownload(...a),
|
||||||
checkZip: (...a: unknown[]) => mockCheckZip(...a),
|
checkZip: (...a: unknown[]) => mockCheckZip(...a),
|
||||||
|
getSharePolicy: (...a: unknown[]) => mockPolicy(...a),
|
||||||
|
listSharesForPath: (...a: unknown[]) => mockSharesForPath(...a),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock('@/lib/nextcloud-files-upload', () => ({
|
vi.mock('@/lib/nextcloud-files-upload', () => ({
|
||||||
@@ -61,6 +65,7 @@ function entry(path: string, over: Partial<NcEntry> = {}): NcEntry {
|
|||||||
permissions: 'RGDNVW',
|
permissions: 'RGDNVW',
|
||||||
hasPreview: false,
|
hasPreview: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
shareTypes: [],
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,6 +122,30 @@ beforeEach(() => {
|
|||||||
mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 });
|
mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 });
|
||||||
mockCheckDownload.mockReset().mockResolvedValue({ ok: true });
|
mockCheckDownload.mockReset().mockResolvedValue({ ok: true });
|
||||||
mockCheckZip.mockReset().mockResolvedValue({ ok: true });
|
mockCheckZip.mockReset().mockResolvedValue({ ok: true });
|
||||||
|
mockPolicy.mockReset().mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
groupsEnabled: true,
|
||||||
|
links: {
|
||||||
|
enabled: true,
|
||||||
|
passwordRequired: false,
|
||||||
|
passwordSuggested: false,
|
||||||
|
expiryDefaultDays: null,
|
||||||
|
expiryEnforced: false,
|
||||||
|
uploadAllowed: true,
|
||||||
|
multipleLinks: true,
|
||||||
|
},
|
||||||
|
internalExpiry: { defaultDays: null, enforced: false },
|
||||||
|
minSearchLength: 0,
|
||||||
|
passwordMinLength: 10,
|
||||||
|
});
|
||||||
|
mockSharesForPath
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (p: string) => ({
|
||||||
|
path: p,
|
||||||
|
shares: [],
|
||||||
|
hidden: 0,
|
||||||
|
truncated: false,
|
||||||
|
}));
|
||||||
onExpired = vi.fn();
|
onExpired = vi.fn();
|
||||||
clicked = [];
|
clicked = [];
|
||||||
submitted = [];
|
submitted = [];
|
||||||
@@ -379,6 +408,7 @@ describe('FileBrowser — Liste', () => {
|
|||||||
'Herunterladen',
|
'Herunterladen',
|
||||||
'Umbenennen',
|
'Umbenennen',
|
||||||
'Verschieben',
|
'Verschieben',
|
||||||
|
'Teilen',
|
||||||
'In Nextcloud öffnen',
|
'In Nextcloud öffnen',
|
||||||
'Löschen',
|
'Löschen',
|
||||||
]);
|
]);
|
||||||
@@ -634,3 +664,100 @@ describe('FileBrowser — Vorschaubilder (WR-03)', () => {
|
|||||||
expect(mockList.mock.calls.length - callsBefore).toBe(1);
|
expect(mockList.mock.calls.length - callsBefore).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FileBrowser — Teilen (quick-261009-dkv)', () => {
|
||||||
|
it('das Menue bietet Teilen nur bei Eintraegen mit dem Buchstaben R und oeffnet den Dialog', async () => {
|
||||||
|
mockList.mockImplementation(async (p: string) =>
|
||||||
|
listing(
|
||||||
|
p,
|
||||||
|
p === '/'
|
||||||
|
? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'GVW' })]
|
||||||
|
: (FOLDERS[p] ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
fireEvent.contextMenu(screen.getByRole('row', { name: 'Fremd.txt' }));
|
||||||
|
expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull();
|
||||||
|
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: '„Projekte“ teilen' });
|
||||||
|
expect(within(dialog).getByRole('heading', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||||
|
await waitFor(() => expect(mockSharesForPath).toHaveBeenCalledWith('/Projekte'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist Teilen ausgeschaltet (sharingEnabled false), fehlen Menueeintrag und Knopf am Kennzeichen', async () => {
|
||||||
|
mockList.mockImplementation(async (p: string) =>
|
||||||
|
listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [0] })] : []),
|
||||||
|
);
|
||||||
|
render(<FileBrowser serverUrl={null} onExpired={onExpired} sharingEnabled={false} />);
|
||||||
|
await screen.findByRole('row', { name: 'Projekte' });
|
||||||
|
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||||
|
expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Freigaben von „Projekte“ öffnen' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein geteilter Eintrag traegt den Knopf am Kennzeichen, der den Dialog oeffnet', async () => {
|
||||||
|
mockList.mockImplementation(async (p: string) =>
|
||||||
|
listing(
|
||||||
|
p,
|
||||||
|
p === '/'
|
||||||
|
? [folder('/Projekte', { shareTypes: [0] }), entry('/Bericht.pdf', { shareTypes: [3] })]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
const row = screen.getByRole('row', { name: 'Projekte' });
|
||||||
|
fireEvent.click(within(row).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' }));
|
||||||
|
expect(await screen.findByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||||
|
// Das Kennzeichen waehlt die Zeile nicht aus.
|
||||||
|
expect(row.getAttribute('aria-selected')).toBe('false');
|
||||||
|
// Der zweite Eintrag hat ebenfalls ein Kennzeichen
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole('row', { name: 'Bericht.pdf' })).getByRole('button', {
|
||||||
|
name: 'Freigaben von „Bericht.pdf“ öffnen',
|
||||||
|
}),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Eintrag mit S in den Berechtigungen zeigt die Markierung "Mit Ihnen geteilt"', async () => {
|
||||||
|
mockList.mockImplementation(async (p: string) =>
|
||||||
|
listing(
|
||||||
|
p,
|
||||||
|
p === '/' ? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'SGVW' })] : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
const row = screen.getByRole('row', { name: 'Fremd.txt' });
|
||||||
|
expect(within(row).getByTestId('share-incoming').getAttribute('title')).toBe(
|
||||||
|
'Mit Ihnen geteilt',
|
||||||
|
);
|
||||||
|
expect(within(row).getByText('Mit Ihnen geteilt')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole('row', { name: 'Projekte' })).queryByTestId('share-incoming'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('im Raster erscheint das Kennzeichen ebenfalls', async () => {
|
||||||
|
mockList.mockImplementation(async (p: string) =>
|
||||||
|
listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [1] })] : []),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Raster' }));
|
||||||
|
const grid = await screen.findByTestId('nc-files-grid');
|
||||||
|
expect(
|
||||||
|
within(grid).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein abgelaufener Zugang im Dialog fuehrt zurueck zum Anmeldebildschirm', async () => {
|
||||||
|
mockPolicy.mockRejectedValue(
|
||||||
|
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' }));
|
||||||
|
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { collectDrop, hasFiles, snapshotDrop } from '@/components/nextcloud-file
|
|||||||
import { type ErrorLike, errorText, toErrorLike } from '@/components/nextcloud-files/error-text';
|
import { type ErrorLike, errorText, toErrorLike } from '@/components/nextcloud-files/error-text';
|
||||||
import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-files/paths';
|
import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-files/paths';
|
||||||
import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection';
|
import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection';
|
||||||
|
import { type ShareTarget, targetFromEntry } from '@/components/nextcloud-files/share-policy';
|
||||||
import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers';
|
import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers';
|
||||||
import {
|
import {
|
||||||
checkDownload,
|
checkDownload,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
OpenIcon,
|
OpenIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
RetryIcon,
|
RetryIcon,
|
||||||
|
ShareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
@@ -42,6 +44,7 @@ import { MoveDialog } from './MoveDialog';
|
|||||||
import { NameDialog } from './NameDialog';
|
import { NameDialog } from './NameDialog';
|
||||||
import { QuotaMeter } from './QuotaMeter';
|
import { QuotaMeter } from './QuotaMeter';
|
||||||
import { SelectionBar } from './SelectionBar';
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { ShareDialog } from './ShareDialog';
|
||||||
import { type SortDir, type SortKey, Toolbar, type ViewMode } from './Toolbar';
|
import { type SortDir, type SortKey, Toolbar, type ViewMode } from './Toolbar';
|
||||||
import { TransferBar } from './TransferBar';
|
import { TransferBar } from './TransferBar';
|
||||||
|
|
||||||
@@ -52,7 +55,8 @@ type DialogState =
|
|||||||
| { kind: 'newFolder' }
|
| { kind: 'newFolder' }
|
||||||
| { kind: 'rename'; entry: NcEntry }
|
| { kind: 'rename'; entry: NcEntry }
|
||||||
| { kind: 'move'; entries: NcEntry[] }
|
| { kind: 'move'; entries: NcEntry[] }
|
||||||
| { kind: 'delete'; entries: NcEntry[] };
|
| { kind: 'delete'; entries: NcEntry[] }
|
||||||
|
| { kind: 'share'; target: ShareTarget };
|
||||||
|
|
||||||
interface MenuState {
|
interface MenuState {
|
||||||
entry: NcEntry;
|
entry: NcEntry;
|
||||||
@@ -143,11 +147,14 @@ function isTypingTarget(el: EventTarget | null): boolean {
|
|||||||
export function FileBrowser({
|
export function FileBrowser({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
onExpired,
|
onExpired,
|
||||||
|
sharingEnabled = true,
|
||||||
}: {
|
}: {
|
||||||
/** Adresse der Nextcloud fuer "In Nextcloud öffnen". */
|
/** Adresse der Nextcloud fuer "In Nextcloud öffnen". */
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
/** Die Verbindung ist abgelaufen: die Seite zeigt wieder den Anmeldebildschirm. */
|
/** Die Verbindung ist abgelaufen: die Seite zeigt wieder den Anmeldebildschirm. */
|
||||||
onExpired: () => void;
|
onExpired: () => void;
|
||||||
|
/** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */
|
||||||
|
sharingEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('nextcloudFiles.browser');
|
const t = useTranslations('nextcloudFiles.browser');
|
||||||
const tCodes = useTranslations('nextcloudFiles.codes');
|
const tCodes = useTranslations('nextcloudFiles.codes');
|
||||||
@@ -672,6 +679,15 @@ export function FileBrowser({
|
|||||||
onSelect: () => setDialog({ kind: 'move', entries: [entry] }),
|
onSelect: () => setDialog({ kind: 'move', entries: [entry] }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Nur Eintraege, die Nextcloud als teilbar kennzeichnet (Buchstabe R).
|
||||||
|
if (sharingEnabled && entry.permissions.includes('R')) {
|
||||||
|
actions.push({
|
||||||
|
id: 'share',
|
||||||
|
label: t('menu.share'),
|
||||||
|
icon: <ShareIcon />,
|
||||||
|
onSelect: () => setDialog({ kind: 'share', target: targetFromEntry(entry) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (serverUrl && entry.fileId) {
|
if (serverUrl && entry.fileId) {
|
||||||
actions.push({
|
actions.push({
|
||||||
id: 'openInNextcloud',
|
id: 'openInNextcloud',
|
||||||
@@ -856,6 +872,9 @@ export function FileBrowser({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu: openMenu,
|
onMenu: openMenu,
|
||||||
onFocusItem: setFocusIndex,
|
onFocusItem: setFocusIndex,
|
||||||
|
onShare: sharingEnabled
|
||||||
|
? (entry: NcEntry) => setDialog({ kind: 'share', target: targetFromEntry(entry) })
|
||||||
|
: undefined,
|
||||||
onPreviewError,
|
onPreviewError,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1072,6 +1091,14 @@ export function FileBrowser({
|
|||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{dialog?.kind === 'share' && (
|
||||||
|
<ShareDialog
|
||||||
|
target={dialog.target}
|
||||||
|
onChanged={() => void load(pathRef.current, { quiet: true })}
|
||||||
|
onExpired={() => onExpiredRef.current()}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fileTypeOf } from '@/components/nextcloud-files/file-types';
|
|||||||
import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api';
|
import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api';
|
||||||
import type { EntryViewProps } from './FileList';
|
import type { EntryViewProps } from './FileList';
|
||||||
import { CheckIcon, MoreIcon } from './icons';
|
import { CheckIcon, MoreIcon } from './icons';
|
||||||
|
import { ShareIndicator } from './ShareIndicator';
|
||||||
import { TypeTile } from './TypeTile';
|
import { TypeTile } from './TypeTile';
|
||||||
|
|
||||||
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
|
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
|
||||||
@@ -54,6 +55,7 @@ export function FileGrid({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu,
|
onMenu,
|
||||||
onFocusItem,
|
onFocusItem,
|
||||||
|
onShare,
|
||||||
onPreviewError,
|
onPreviewError,
|
||||||
}: EntryViewProps) {
|
}: EntryViewProps) {
|
||||||
const t = useTranslations('nextcloudFiles.browser');
|
const t = useTranslations('nextcloudFiles.browser');
|
||||||
@@ -169,12 +171,18 @@ export function FileGrid({
|
|||||||
{entry.name}
|
{entry.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5">
|
||||||
<p
|
<p
|
||||||
className="mt-0.5 text-xs tabular-nums text-muted-foreground"
|
className="text-xs tabular-nums text-muted-foreground"
|
||||||
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
||||||
>
|
>
|
||||||
{formatSize(entry.size, locale)}
|
{formatSize(entry.size, locale)}
|
||||||
</p>
|
</p>
|
||||||
|
<ShareIndicator
|
||||||
|
entry={entry}
|
||||||
|
onShare={onShare ? () => onShare(entry) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { downloadUrl, type NcEntry } from '@/lib/nextcloud-files-api';
|
import { downloadUrl, type NcEntry } from '@/lib/nextcloud-files-api';
|
||||||
import { ChevronDownIcon, ChevronUpIcon, MoreIcon } from './icons';
|
import { ChevronDownIcon, ChevronUpIcon, MoreIcon } from './icons';
|
||||||
import type { SortDir, SortKey } from './Toolbar';
|
import type { SortDir, SortKey } from './Toolbar';
|
||||||
|
import { ShareIndicator } from './ShareIndicator';
|
||||||
import { TypeTile } from './TypeTile';
|
import { TypeTile } from './TypeTile';
|
||||||
|
|
||||||
/** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */
|
/** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */
|
||||||
@@ -29,6 +30,8 @@ export interface EntryViewProps {
|
|||||||
onToggle: (entry: NcEntry, index: number) => void;
|
onToggle: (entry: NcEntry, index: number) => void;
|
||||||
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
|
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
|
||||||
onFocusItem: (index: number) => void;
|
onFocusItem: (index: number) => void;
|
||||||
|
/** Teilen-Dialog eines Eintrags oeffnen (Kennzeichen); fehlt sie, ist Teilen ausgeschaltet. */
|
||||||
|
onShare?: (entry: NcEntry) => void;
|
||||||
/**
|
/**
|
||||||
* Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die
|
* Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die
|
||||||
* Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck.
|
* Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck.
|
||||||
@@ -55,6 +58,7 @@ export function FileList({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu,
|
onMenu,
|
||||||
onFocusItem,
|
onFocusItem,
|
||||||
|
onShare,
|
||||||
onPreviewError,
|
onPreviewError,
|
||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
@@ -221,6 +225,10 @@ export function FileList({
|
|||||||
{entry.name}
|
{entry.name}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
<ShareIndicator
|
||||||
|
entry={entry}
|
||||||
|
onShare={onShare ? () => onShare(entry) : undefined}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap py-0 pr-1 text-right text-[13px] tabular-nums text-muted-foreground">
|
<td className="whitespace-nowrap py-0 pr-1 text-right text-[13px] tabular-nums text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ShareTarget } from '@/components/nextcloud-files/share-policy';
|
||||||
|
import {
|
||||||
|
type NcShare,
|
||||||
|
type NcSharee,
|
||||||
|
type NcSharePolicy,
|
||||||
|
NextcloudFilesRequestError,
|
||||||
|
} from '@/lib/nextcloud-files-api';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import { ShareDialog } from './ShareDialog';
|
||||||
|
|
||||||
|
const mockPolicy = vi.fn();
|
||||||
|
const mockList = vi.fn();
|
||||||
|
const mockSearch = vi.fn();
|
||||||
|
const mockCreate = vi.fn();
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getSharePolicy: (...a: unknown[]) => mockPolicy(...a),
|
||||||
|
listSharesForPath: (...a: unknown[]) => mockList(...a),
|
||||||
|
searchSharees: (...a: unknown[]) => mockSearch(...a),
|
||||||
|
createShare: (...a: unknown[]) => mockCreate(...a),
|
||||||
|
updateShare: (...a: unknown[]) => mockUpdate(...a),
|
||||||
|
deleteShare: (...a: unknown[]) => mockDelete(...a),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const POLICY: NcSharePolicy = {
|
||||||
|
enabled: true,
|
||||||
|
groupsEnabled: true,
|
||||||
|
links: {
|
||||||
|
enabled: true,
|
||||||
|
passwordRequired: false,
|
||||||
|
passwordSuggested: false,
|
||||||
|
expiryDefaultDays: null,
|
||||||
|
expiryEnforced: false,
|
||||||
|
uploadAllowed: true,
|
||||||
|
multipleLinks: true,
|
||||||
|
},
|
||||||
|
internalExpiry: { defaultDays: null, enforced: false },
|
||||||
|
minSearchLength: 0,
|
||||||
|
passwordMinLength: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOLDER: ShareTarget = {
|
||||||
|
path: '/Projekte',
|
||||||
|
name: 'Projekte',
|
||||||
|
type: 'folder',
|
||||||
|
mime: null,
|
||||||
|
writable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function share(over: Partial<NcShare> = {}): NcShare {
|
||||||
|
return {
|
||||||
|
id: '17',
|
||||||
|
kind: 'user',
|
||||||
|
path: '/Projekte',
|
||||||
|
name: 'Projekte',
|
||||||
|
itemType: 'folder',
|
||||||
|
mime: null,
|
||||||
|
itemWritable: true,
|
||||||
|
permissions: 1,
|
||||||
|
access: 'view',
|
||||||
|
shareWith: 'ben',
|
||||||
|
shareWithName: 'Ben Beispiel',
|
||||||
|
ownerId: 'anna',
|
||||||
|
ownerName: 'Anna Müller',
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: true,
|
||||||
|
expiration: null,
|
||||||
|
label: '',
|
||||||
|
url: null,
|
||||||
|
hasPassword: false,
|
||||||
|
target: '/Projekte',
|
||||||
|
sharedAt: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEN: NcSharee = { kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' };
|
||||||
|
const ZOE: NcSharee = { kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' };
|
||||||
|
const TEAM: NcSharee = { kind: 'group', id: 'tessera-team', label: 'tessera-team', detail: null };
|
||||||
|
|
||||||
|
let onClose: ReturnType<typeof vi.fn>;
|
||||||
|
let onChanged: ReturnType<typeof vi.fn>;
|
||||||
|
let onExpired: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function mount(target: ShareTarget = FOLDER) {
|
||||||
|
return render(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
<ShareDialog target={target} onClose={onClose} onChanged={onChanged} onExpired={onExpired} />
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready() {
|
||||||
|
return screen.findByRole('combobox', { name: 'Person oder Gruppe suchen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(input: HTMLElement, value: string) {
|
||||||
|
fireEvent.change(input, { target: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
onClose = vi.fn();
|
||||||
|
onChanged = vi.fn();
|
||||||
|
onExpired = vi.fn();
|
||||||
|
mockPolicy.mockReset().mockResolvedValue(POLICY);
|
||||||
|
mockList
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ path: '/Projekte', shares: [], hidden: 0, truncated: false });
|
||||||
|
mockSearch.mockReset().mockResolvedValue({ sharees: [BEN, ZOE, TEAM] });
|
||||||
|
mockCreate
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (input: { shareWith: string; kind: string }) =>
|
||||||
|
share({ id: '99', shareWith: input.shareWith, kind: input.kind as 'user' | 'group' }),
|
||||||
|
);
|
||||||
|
mockUpdate
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (_id: string, input: { access: 'view' | 'edit' }) =>
|
||||||
|
share({ access: input.access, permissions: input.access === 'edit' ? 15 : 1 }),
|
||||||
|
);
|
||||||
|
mockDelete.mockReset().mockResolvedValue({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('ShareDialog — Personen und Gruppen', () => {
|
||||||
|
it('zeigt die vorhandenen Freigaben mit Gruppenkennzeichen und Ablaufdatum', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
path: '/Projekte',
|
||||||
|
shares: [
|
||||||
|
share(),
|
||||||
|
share({
|
||||||
|
id: '18',
|
||||||
|
kind: 'group',
|
||||||
|
shareWith: 'tessera-team',
|
||||||
|
shareWithName: 'tessera-team',
|
||||||
|
expiration: '2026-12-31',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
hidden: 2,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await ready();
|
||||||
|
expect(screen.getByText('Ben Beispiel')).toBeTruthy();
|
||||||
|
const teamRow = screen.getByText('tessera-team').closest('li') as HTMLElement;
|
||||||
|
expect(within(teamRow).getByText('Gruppe')).toBeTruthy();
|
||||||
|
expect(within(teamRow).getByText('gültig bis 31.12.2026')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText('2 weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(mockList).toHaveBeenCalledWith('/Projekte');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tippen ruft die Suche einmal nach der Verzoegerung auf; Auswaehlen legt genau eine Freigabe an', async () => {
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'b');
|
||||||
|
type(input, 'be');
|
||||||
|
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockSearch).toHaveBeenCalledWith('be', 'folder');
|
||||||
|
const list = await screen.findByRole('listbox', { name: 'Suchergebnisse' });
|
||||||
|
expect(within(list).getAllByRole('option')).toHaveLength(3);
|
||||||
|
fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ }));
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
path: '/Projekte',
|
||||||
|
kind: 'user',
|
||||||
|
shareWith: 'ben',
|
||||||
|
access: 'view',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||||
|
expect((input as HTMLInputElement).value).toBe('');
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die gewaehlte Berechtigung gilt fuer die naechste Freigabe; eine Gruppe wird als Gruppe angelegt', async () => {
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' }),
|
||||||
|
{
|
||||||
|
target: { value: 'edit' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
type(input, 'tes');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
fireEvent.click(within(list).getByRole('option', { name: /tessera-team/ }));
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
path: '/Projekte',
|
||||||
|
kind: 'group',
|
||||||
|
shareWith: 'tessera-team',
|
||||||
|
access: 'edit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein bereits geteilter Empfaenger ist gesperrt und legt nichts an', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
path: '/Projekte',
|
||||||
|
shares: [share()],
|
||||||
|
hidden: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'be');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
const option = within(list).getByRole('option', { name: /Ben Beispiel/ });
|
||||||
|
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(within(option).getByText('bereits geteilt')).toBeTruthy();
|
||||||
|
fireEvent.click(option);
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Pfeiltasten und Enter waehlen; Escape schliesst zuerst nur die Ergebnisliste', async () => {
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'be');
|
||||||
|
await screen.findByRole('listbox');
|
||||||
|
fireEvent.keyDown(input, { key: 'Escape' });
|
||||||
|
expect(screen.queryByRole('listbox')).toBeNull();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
const active = input.getAttribute('aria-activedescendant');
|
||||||
|
expect(active).toBeTruthy();
|
||||||
|
expect(document.getElementById(active as string)?.textContent).toContain('Zwei Faktor');
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockCreate.mock.calls[0][0]).toMatchObject({ shareWith: 'zoe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist die Gruppenfreigabe ausgeschaltet, erscheinen keine Gruppen in der Suche', async () => {
|
||||||
|
mockPolicy.mockResolvedValue({ ...POLICY, groupsEnabled: false });
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'te');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
expect(within(list).queryByRole('option', { name: /tessera-team/ })).toBeNull();
|
||||||
|
expect(within(list).getAllByRole('option')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Eintrag ohne Schreibrecht bietet nur "Ansehen"', async () => {
|
||||||
|
mount({ ...FOLDER, writable: false });
|
||||||
|
await ready();
|
||||||
|
const select = screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' });
|
||||||
|
expect(
|
||||||
|
within(select)
|
||||||
|
.getAllByRole('option')
|
||||||
|
.map((o) => o.textContent),
|
||||||
|
).toEqual(['Ansehen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Berechtigung einer Zeile aendern ruft updateShare mit der Auswahl, nie mit einer Maske', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
path: '/Projekte',
|
||||||
|
shares: [share()],
|
||||||
|
hidden: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await ready();
|
||||||
|
fireEvent.change(screen.getByRole('combobox', { name: 'Berechtigung für Ben Beispiel' }), {
|
||||||
|
target: { value: 'edit' },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith('17', { access: 'edit' });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
screen.getByRole('combobox', {
|
||||||
|
name: 'Berechtigung für Ben Beispiel',
|
||||||
|
}) as HTMLSelectElement
|
||||||
|
).value,
|
||||||
|
).toBe('edit'),
|
||||||
|
);
|
||||||
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Entfernen ruft deleteShare und nimmt die Zeile heraus', async () => {
|
||||||
|
mockList.mockResolvedValue({
|
||||||
|
path: '/Projekte',
|
||||||
|
shares: [share()],
|
||||||
|
hidden: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await ready();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }));
|
||||||
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('17'));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Ben Beispiel')).toBeNull());
|
||||||
|
expect(screen.getByText('Noch mit niemandem geteilt.')).toBeTruthy();
|
||||||
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shareRejected zeigt den deutschen Text und die Meldung der Nextcloud als zweite Zeile', async () => {
|
||||||
|
mockCreate.mockRejectedValue(
|
||||||
|
new NextcloudFilesRequestError(
|
||||||
|
422,
|
||||||
|
'shareRejected',
|
||||||
|
'Nextcloud hat diese Freigabe abgelehnt.',
|
||||||
|
{
|
||||||
|
ncMessage: 'You cannot share a folder that contains other shares',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'be');
|
||||||
|
const list = await screen.findByRole('listbox');
|
||||||
|
fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ }));
|
||||||
|
const alert = await screen.findByRole('alert');
|
||||||
|
expect(alert.textContent).toContain('Nextcloud hat diese Freigabe abgelehnt.');
|
||||||
|
expect(alert.textContent).toContain(
|
||||||
|
'Meldung der Nextcloud: You cannot share a folder that contains other shares',
|
||||||
|
);
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tooManyShares nennt die Wartezeit in Minuten', async () => {
|
||||||
|
mockCreate.mockRejectedValue(
|
||||||
|
new NextcloudFilesRequestError(429, 'tooManyShares', 'x', { retryAfterSeconds: 585 }),
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'be');
|
||||||
|
fireEvent.click(
|
||||||
|
within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }),
|
||||||
|
);
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||||
|
'Bitte warten Sie 10 Minuten.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connectionExpired ruft onExpired', async () => {
|
||||||
|
mockCreate.mockRejectedValue(
|
||||||
|
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||||
|
);
|
||||||
|
mount();
|
||||||
|
const input = await ready();
|
||||||
|
type(input, 'be');
|
||||||
|
fireEvent.click(
|
||||||
|
within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Teilen in der Nextcloud ausgeschaltet: nur der Hinweis, kein Suchfeld', async () => {
|
||||||
|
mockPolicy.mockResolvedValue({ ...POLICY, enabled: false });
|
||||||
|
mount();
|
||||||
|
expect(await screen.findByText('Teilen ist in Ihrer Nextcloud ausgeschaltet.')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('combobox')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fertig schliesst den Dialog; der Titel nennt den Namen', async () => {
|
||||||
|
mount();
|
||||||
|
await ready();
|
||||||
|
expect(screen.getByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Fertig' }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
type ErrorLike,
|
||||||
|
errorText,
|
||||||
|
ncMessageOf,
|
||||||
|
toErrorLike,
|
||||||
|
} from '@/components/nextcloud-files/error-text';
|
||||||
|
import {
|
||||||
|
accessOptions,
|
||||||
|
type ShareAccessChoice,
|
||||||
|
type ShareTarget,
|
||||||
|
} from '@/components/nextcloud-files/share-policy';
|
||||||
|
import {
|
||||||
|
createShare,
|
||||||
|
deleteShare,
|
||||||
|
getSharePolicy,
|
||||||
|
listSharesForPath,
|
||||||
|
type NcShare,
|
||||||
|
type NcSharee,
|
||||||
|
type NcSharePolicy,
|
||||||
|
searchSharees,
|
||||||
|
updateShare,
|
||||||
|
} from '@/lib/nextcloud-files-api';
|
||||||
|
import { Dialog } from './Dialog';
|
||||||
|
import { CloseIcon, UserIcon, UsersIcon } from './icons';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
const SELECT_CLASS =
|
||||||
|
'rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60';
|
||||||
|
|
||||||
|
/** Datum `YYYY-MM-DD` in der Landessprache (Mittag, damit keine Zeitzone den Tag verschiebt). */
|
||||||
|
function dateText(date: string, locale: string): string {
|
||||||
|
const d = new Date(`${date}T12:00:00`);
|
||||||
|
return Number.isNaN(d.getTime()) ? date : d.toLocaleDateString(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teilen-Dialog (quick-261009-dkv, D-19): Personen und Gruppen suchen und hinzufuegen,
|
||||||
|
* Berechtigung aendern, Freigabe entfernen. Gebaut auf `Dialog`, damit die Tastenbelegung der
|
||||||
|
* Dateiansicht (Entf, F2 ...) beim Tippen im Suchfeld nicht greift. Alle Regeln (was angeboten
|
||||||
|
* wird, ob Gruppen gehen) kommen frisch von der Nextcloud ueber die API; Fehler erscheinen mit
|
||||||
|
* dem eigenen Text und, falls vorhanden, der Meldung der Nextcloud als zweiter Zeile.
|
||||||
|
*/
|
||||||
|
export function ShareDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
onExpired,
|
||||||
|
}: {
|
||||||
|
target: ShareTarget;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Nach jeder gelungenen Aenderung (die Dateiliste laedt neu). */
|
||||||
|
onChanged: () => void;
|
||||||
|
/** Die Verbindung ist abgelaufen. */
|
||||||
|
onExpired: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('nextcloudFiles.share');
|
||||||
|
const tCodes = useTranslations('nextcloudFiles.codes');
|
||||||
|
const locale = useLocale();
|
||||||
|
const ids = useId();
|
||||||
|
const inputId = `${ids}-search`;
|
||||||
|
const listId = `${ids}-list`;
|
||||||
|
const optionId = (i: number) => `${ids}-opt-${i}`;
|
||||||
|
|
||||||
|
const [policy, setPolicy] = useState<NcSharePolicy | null>(null);
|
||||||
|
const [shares, setShares] = useState<NcShare[]>([]);
|
||||||
|
const [hidden, setHidden] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<ErrorLike | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
const [results, setResults] = useState<NcSharee[]>([]);
|
||||||
|
const [searchedFor, setSearchedFor] = useState<string | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [active, setActive] = useState(-1);
|
||||||
|
const [newAccess, setNewAccess] = useState<ShareAccessChoice>('view');
|
||||||
|
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const searchSeq = useRef(0);
|
||||||
|
const onExpiredRef = useRef(onExpired);
|
||||||
|
onExpiredRef.current = onExpired;
|
||||||
|
|
||||||
|
const fail = (err: unknown) => {
|
||||||
|
const e = toErrorLike(err);
|
||||||
|
if (e.code === 'connectionExpired') {
|
||||||
|
onExpiredRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(e);
|
||||||
|
};
|
||||||
|
const failRef = useRef(fail);
|
||||||
|
failRef.current = fail;
|
||||||
|
|
||||||
|
// Regeln und vorhandene Freigaben laden.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([getSharePolicy(), listSharesForPath(target.path)])
|
||||||
|
.then(([p, list]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setPolicy(p);
|
||||||
|
setShares(list.shares);
|
||||||
|
setHidden(list.hidden);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) failRef.current(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [target.path]);
|
||||||
|
|
||||||
|
const enabled = policy?.enabled === true;
|
||||||
|
const minChars = Math.max(1, policy?.minSearchLength ?? 1);
|
||||||
|
|
||||||
|
// Sobald der Dialog bereit ist, steht der Cursor im Suchfeld.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && enabled) input.current?.focus();
|
||||||
|
}, [loading, enabled]);
|
||||||
|
|
||||||
|
// Suche mit Verzoegerung; veraltete Antworten werden verworfen.
|
||||||
|
useEffect(() => {
|
||||||
|
const seq = ++searchSeq.current;
|
||||||
|
const q = term.trim();
|
||||||
|
if (!policy?.enabled || q.length < minChars) {
|
||||||
|
setResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
setOpen(false);
|
||||||
|
setSearchedFor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
searchSharees(q, target.type)
|
||||||
|
.then((out) => {
|
||||||
|
if (seq !== searchSeq.current) return;
|
||||||
|
setResults(out.sharees.filter((s) => s.kind === 'user' || policy.groupsEnabled));
|
||||||
|
setSearchedFor(q);
|
||||||
|
setActive(-1);
|
||||||
|
setOpen(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (seq !== searchSeq.current) return;
|
||||||
|
setResults([]);
|
||||||
|
failRef.current(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (seq === searchSeq.current) setSearching(false);
|
||||||
|
});
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [term, policy, target.type, minChars]);
|
||||||
|
|
||||||
|
const sharedKeys = useMemo(
|
||||||
|
() => new Set(shares.filter((s) => s.kind !== 'link').map((s) => `${s.kind}:${s.shareWith}`)),
|
||||||
|
[shares],
|
||||||
|
);
|
||||||
|
const people = shares.filter((s) => s.kind !== 'link');
|
||||||
|
const options = policy
|
||||||
|
? accessOptions(target, 'user', policy)
|
||||||
|
: (['view'] as ShareAccessChoice[]);
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<void>) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (err) {
|
||||||
|
fail(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (s: NcSharee) => {
|
||||||
|
if (sharedKeys.has(`${s.kind}:${s.id}`)) return;
|
||||||
|
void run(async () => {
|
||||||
|
const created = await createShare({
|
||||||
|
path: target.path,
|
||||||
|
kind: s.kind,
|
||||||
|
shareWith: s.id,
|
||||||
|
access: newAccess,
|
||||||
|
});
|
||||||
|
setShares((prev) => [...prev, created]);
|
||||||
|
setTerm('');
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
onChanged();
|
||||||
|
input.current?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeAccess = (share: NcShare, access: ShareAccessChoice) =>
|
||||||
|
run(async () => {
|
||||||
|
const updated = await updateShare(share.id, { access });
|
||||||
|
setShares((prev) => prev.map((s) => (s.id === share.id ? updated : s)));
|
||||||
|
onChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = (share: NcShare) =>
|
||||||
|
run(async () => {
|
||||||
|
await deleteShare(share.id);
|
||||||
|
setShares((prev) => prev.filter((s) => s.id !== share.id));
|
||||||
|
onChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length === 0) return;
|
||||||
|
setOpen(true);
|
||||||
|
setActive((a) => Math.min(results.length - 1, a + 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((a) => Math.max(0, a - 1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (open && active >= 0 && results[active]) {
|
||||||
|
e.preventDefault();
|
||||||
|
choose(results[active]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape' && open) {
|
||||||
|
// Erst die Ergebnisliste schliessen, nicht gleich den ganzen Dialog.
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameOf = (s: NcShare) => s.shareWithName ?? s.shareWith ?? '';
|
||||||
|
const trimmed = term.trim();
|
||||||
|
const tooShort = trimmed !== '' && trimmed.length < minChars;
|
||||||
|
const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0;
|
||||||
|
const detail = error ? ncMessageOf(error) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
wide
|
||||||
|
title={t('title', { name: target.name })}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onClose}>
|
||||||
|
{t('done')}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p role="status" className="py-4 text-sm text-muted-foreground">
|
||||||
|
{t('loading')}
|
||||||
|
</p>
|
||||||
|
) : !enabled ? (
|
||||||
|
<p role="alert" className="py-2 text-sm text-foreground">
|
||||||
|
{error ? errorText(tCodes, error, locale) : tCodes('sharingDisabled')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5" aria-busy={busy}>
|
||||||
|
<section aria-labelledby={`${ids}-people`}>
|
||||||
|
<h3 id={`${ids}-people`} className="text-sm font-semibold text-foreground">
|
||||||
|
{t('people.title')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<label htmlFor={inputId} className="mt-3 block text-sm text-muted-foreground">
|
||||||
|
{t('people.searchLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-2 sm:flex-nowrap">
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={active >= 0 ? optionId(active) : undefined}
|
||||||
|
value={term}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={busy}
|
||||||
|
placeholder={t('people.searchPlaceholder')}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTerm(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t('people.newAccess')}
|
||||||
|
value={newAccess}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setNewAccess(e.target.value as ShareAccessChoice)}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{t(`access.${o}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="mt-1.5 min-h-5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{searching
|
||||||
|
? t('people.searching')
|
||||||
|
: tooShort
|
||||||
|
? t('people.minChars', { count: minChars })
|
||||||
|
: noResults
|
||||||
|
? t('people.noResults')
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<ul
|
||||||
|
id={listId}
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Ergebnisliste des Suchfelds (ARIA-Combobox-Muster)
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t('people.results')}
|
||||||
|
className="mt-1 max-h-56 overflow-y-auto rounded border border-border bg-card"
|
||||||
|
>
|
||||||
|
{results.map((s, i) => {
|
||||||
|
const already = sharedKeys.has(`${s.kind}:${s.id}`);
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: Tastatur ueber das Suchfeld (Pfeile, Enter)
|
||||||
|
<li
|
||||||
|
key={`${s.kind}:${s.id}`}
|
||||||
|
id={optionId(i)}
|
||||||
|
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Option der Combobox
|
||||||
|
role="option"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-selected={active === i}
|
||||||
|
aria-disabled={already || undefined}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => choose(s)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 text-sm ${
|
||||||
|
already
|
||||||
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
|
: 'cursor-pointer hover:bg-accent/70'
|
||||||
|
} ${active === i ? 'bg-accent' : ''}`}
|
||||||
|
>
|
||||||
|
{s.kind === 'group' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-foreground">{s.label}</span>
|
||||||
|
{s.kind === 'group' && (
|
||||||
|
<span className="text-xs text-muted-foreground">{t('people.group')}</span>
|
||||||
|
)}
|
||||||
|
{s.kind === 'user' && s.detail && s.detail !== s.label && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{s.detail}</span>
|
||||||
|
)}
|
||||||
|
{already && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('people.alreadyShared')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">{t('people.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 divide-y divide-border/70 rounded border border-border">
|
||||||
|
{people.map((share) => {
|
||||||
|
const rowOptions = options.includes(share.access as ShareAccessChoice)
|
||||||
|
? options
|
||||||
|
: [share.access, ...options];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={share.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-well text-muted-foreground"
|
||||||
|
>
|
||||||
|
{share.kind === 'group' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{nameOf(share)}
|
||||||
|
{share.kind === 'group' && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
{t('people.group')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{share.expiration && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('people.expires', { date: dateText(share.expiration, locale) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label={t('people.accessFor', { name: nameOf(share) })}
|
||||||
|
value={share.access}
|
||||||
|
disabled={busy || !share.canEdit}
|
||||||
|
onChange={(e) =>
|
||||||
|
void changeAccess(share, e.target.value as ShareAccessChoice)
|
||||||
|
}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
|
{rowOptions.map((o) => (
|
||||||
|
<option key={o} value={o} disabled={o === 'custom'}>
|
||||||
|
{t(`access.${o}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('people.remove', { name: nameOf(share) })}
|
||||||
|
title={t('people.remove', { name: nameOf(share) })}
|
||||||
|
disabled={busy || !share.canDelete}
|
||||||
|
onClick={() => void remove(share)}
|
||||||
|
className="btn btn-subtle btn-icon h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<CloseIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hidden > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{t('hidden', { count: hidden })}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && enabled && (
|
||||||
|
<div role="alert" className="mt-4 text-sm text-status-down-fg">
|
||||||
|
<p>{errorText(tCodes, error, locale)}</p>
|
||||||
|
{detail && (
|
||||||
|
<p className="mt-0.5 text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !enabled && !loading && detail && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type { NcEntry } from '@/lib/nextcloud-files-api';
|
||||||
|
import { LinkIcon, UsersIcon } from './icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigabe-Kennzeichen am Namen eines Eintrags (quick-261009-dkv, D-19):
|
||||||
|
* - ausgehend (`oc:share-types` nicht leer): Knopf mit Link-Symbol, wenn ein Link besteht,
|
||||||
|
* sonst mit Personen-Symbol; er oeffnet den Teilen-Dialog.
|
||||||
|
* - eingehend (Buchstabe S in den Berechtigungen): feststehendes Symbol mit Hinweistext,
|
||||||
|
* ohne Knopf, weil fremde Freigaben hier nicht verwaltet werden.
|
||||||
|
* Die Kennzeichen sind ruhige Symbole in gedaempfter Farbe, keine Etiketten.
|
||||||
|
*/
|
||||||
|
export function ShareIndicator({
|
||||||
|
entry,
|
||||||
|
onShare,
|
||||||
|
}: {
|
||||||
|
entry: Pick<NcEntry, 'name' | 'shareTypes' | 'permissions'>;
|
||||||
|
/** Fehlt sie (Teilen ausgeschaltet), bleibt auch das ausgehende Kennzeichen ein Symbol. */
|
||||||
|
onShare?: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('nextcloudFiles.share.indicator');
|
||||||
|
const outgoing = entry.shareTypes.length > 0;
|
||||||
|
const incoming = !outgoing && entry.permissions.includes('S');
|
||||||
|
if (!outgoing && !incoming) return null;
|
||||||
|
|
||||||
|
if (incoming) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="share-incoming"
|
||||||
|
title={t('incoming')}
|
||||||
|
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<UsersIcon size={15} />
|
||||||
|
<span className="sr-only">{t('incoming')}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = entry.shareTypes.includes(3) ? LinkIcon : UsersIcon;
|
||||||
|
const label = t('outgoing', { name: entry.name });
|
||||||
|
if (!onShare) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="share-outgoing"
|
||||||
|
title={label}
|
||||||
|
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid="share-outgoing"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare();
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -161,6 +161,43 @@ export const CloudIcon = (p: P) => (
|
|||||||
</Icon>
|
</Icon>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Teilen (quick-261009-dkv)
|
||||||
|
export const ShareIcon = (p: P) => (
|
||||||
|
<Icon {...p}>
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
|
||||||
|
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
export const LinkIcon = (p: P) => (
|
||||||
|
<Icon {...p}>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
export const UserIcon = (p: P) => (
|
||||||
|
<Icon {...p}>
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
export const UsersIcon = (p: P) => (
|
||||||
|
<Icon {...p}>
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
export const CopyIcon = (p: P) => (
|
||||||
|
<Icon {...p}>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
export const FileGlyph = (p: P) => (
|
export const FileGlyph = (p: P) => (
|
||||||
<Icon {...p}>
|
<Icon {...p}>
|
||||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ beforeEach(() => {
|
|||||||
permissions: 'RGDNVCK',
|
permissions: 'RGDNVCK',
|
||||||
hasPreview: false,
|
hasPreview: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
shareTypes: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
quota: { used: 0, available: null },
|
quota: { used: 0, available: null },
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ const KNOWN = new Set([
|
|||||||
'invalidPath',
|
'invalidPath',
|
||||||
'nextcloudUnavailable',
|
'nextcloudUnavailable',
|
||||||
'selectionTooLarge',
|
'selectionTooLarge',
|
||||||
|
// Teilen (quick-261009-dkv)
|
||||||
|
'sharingDisabled',
|
||||||
|
'linkSharingDisabled',
|
||||||
|
'shareAccessInvalid',
|
||||||
|
'shareRecipientInvalid',
|
||||||
|
'shareAlreadyExists',
|
||||||
|
'sharePasswordRequired',
|
||||||
|
'sharePasswordRejected',
|
||||||
|
'shareExpiryRequired',
|
||||||
|
'shareExpiryInvalid',
|
||||||
|
'shareRejected',
|
||||||
|
'shareNotFound',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface ErrorLike {
|
export interface ErrorLike {
|
||||||
@@ -29,6 +41,17 @@ export interface ErrorLike {
|
|||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Meldung der Nextcloud selbst (uebersetzt in der Sprache ihres Benutzers), falls die API
|
||||||
|
* sie mitgeschickt hat. Nur als zweite Zeile unter dem eigenen Text zeigen, nie auswerten.
|
||||||
|
*/
|
||||||
|
export function ncMessageOf(error: ErrorLike): string | null {
|
||||||
|
const raw = error.extra?.ncMessage;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const text = raw.trim();
|
||||||
|
return text === '' ? null : text;
|
||||||
|
}
|
||||||
|
|
||||||
export function toErrorLike(err: unknown): ErrorLike {
|
export function toErrorLike(err: unknown): ErrorLike {
|
||||||
if (err instanceof NextcloudFilesRequestError) {
|
if (err instanceof NextcloudFilesRequestError) {
|
||||||
return { code: err.code, message: err.message, extra: err.extra };
|
return { code: err.code, message: err.message, extra: err.extra };
|
||||||
@@ -42,13 +65,16 @@ export function toErrorLike(err: unknown): ErrorLike {
|
|||||||
*/
|
*/
|
||||||
export function errorText(t: CodesTranslator, error: ErrorLike, locale: string): string {
|
export function errorText(t: CodesTranslator, error: ErrorLike, locale: string): string {
|
||||||
const code = error.code ?? '';
|
const code = error.code ?? '';
|
||||||
if (code === 'nextcloudLocked') {
|
if (code === 'nextcloudLocked' || code === 'tooManyShares') {
|
||||||
const seconds = Number(error.extra?.retryAfterSeconds);
|
const seconds = Number(error.extra?.retryAfterSeconds);
|
||||||
const minutes = Math.max(
|
const minutes = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil((Number.isFinite(seconds) && seconds > 0 ? seconds : 60) / 60),
|
Math.ceil((Number.isFinite(seconds) && seconds > 0 ? seconds : 60) / 60),
|
||||||
);
|
);
|
||||||
return t('nextcloudLocked', { minutes });
|
return t(code, { minutes });
|
||||||
|
}
|
||||||
|
if (code === 'sharingDisabled' && error.extra?.scope === 'groups') {
|
||||||
|
return t('sharingDisabledGroups');
|
||||||
}
|
}
|
||||||
if (code === 'quotaExceeded' && typeof error.extra?.free === 'number') {
|
if (code === 'quotaExceeded' && typeof error.extra?.free === 'number') {
|
||||||
return t('quotaExceededFree', { free: formatSize(error.extra.free, locale) });
|
return t('quotaExceededFree', { free: formatSize(error.extra.free, locale) });
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { NcSharePolicy } from '@/lib/nextcloud-files-api';
|
||||||
|
import {
|
||||||
|
accessOptions,
|
||||||
|
generatePassword,
|
||||||
|
type RandomFill,
|
||||||
|
type ShareTarget,
|
||||||
|
targetFromEntry,
|
||||||
|
} from './share-policy';
|
||||||
|
|
||||||
|
const POLICY: NcSharePolicy = {
|
||||||
|
enabled: true,
|
||||||
|
groupsEnabled: true,
|
||||||
|
links: {
|
||||||
|
enabled: true,
|
||||||
|
passwordRequired: false,
|
||||||
|
passwordSuggested: false,
|
||||||
|
expiryDefaultDays: null,
|
||||||
|
expiryEnforced: false,
|
||||||
|
uploadAllowed: true,
|
||||||
|
multipleLinks: true,
|
||||||
|
},
|
||||||
|
internalExpiry: { defaultDays: null, enforced: false },
|
||||||
|
minSearchLength: 0,
|
||||||
|
passwordMinLength: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const folder = (writable: boolean): ShareTarget => ({
|
||||||
|
path: '/Projekte',
|
||||||
|
name: 'Projekte',
|
||||||
|
type: 'folder',
|
||||||
|
mime: null,
|
||||||
|
writable,
|
||||||
|
});
|
||||||
|
const file = (writable: boolean): ShareTarget => ({
|
||||||
|
path: '/Bericht.txt',
|
||||||
|
name: 'Bericht.txt',
|
||||||
|
type: 'file',
|
||||||
|
mime: 'text/plain',
|
||||||
|
writable,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('targetFromEntry', () => {
|
||||||
|
const base = { path: '/P', name: 'P', type: 'folder' as const, mime: null };
|
||||||
|
it('beschreibbar bei W, C oder K', () => {
|
||||||
|
expect(targetFromEntry({ ...base, permissions: 'RGDNVCK' }).writable).toBe(true);
|
||||||
|
expect(targetFromEntry({ ...base, permissions: 'RGDNVW' }).writable).toBe(true);
|
||||||
|
expect(targetFromEntry({ ...base, permissions: 'RGC' }).writable).toBe(true);
|
||||||
|
expect(targetFromEntry({ ...base, permissions: 'RG' }).writable).toBe(false);
|
||||||
|
expect(targetFromEntry({ ...base, permissions: '' }).writable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessOptions', () => {
|
||||||
|
it('Person und Gruppe: beschreibbarer Ordner [Ansehen, Bearbeiten], sonst nur Ansehen', () => {
|
||||||
|
expect(accessOptions(folder(true), 'user', POLICY)).toEqual(['view', 'edit']);
|
||||||
|
expect(accessOptions(folder(true), 'group', POLICY)).toEqual(['view', 'edit']);
|
||||||
|
expect(accessOptions(folder(false), 'user', POLICY)).toEqual(['view']);
|
||||||
|
expect(accessOptions(file(true), 'user', POLICY)).toEqual(['view', 'edit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Link: Ordner mit erlaubtem Hochladen [Ansehen, Bearbeiten, Nur hochladen], Datei ohne Hochladen', () => {
|
||||||
|
expect(accessOptions(folder(true), 'link', POLICY)).toEqual(['view', 'edit', 'upload']);
|
||||||
|
expect(accessOptions(file(true), 'link', POLICY)).toEqual(['view', 'edit']);
|
||||||
|
const noUpload = { ...POLICY, links: { ...POLICY.links, uploadAllowed: false } };
|
||||||
|
expect(accessOptions(folder(true), 'link', noUpload)).toEqual(['view']);
|
||||||
|
expect(accessOptions(folder(true), 'link', null)).toEqual(['view']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Deterministische Zufallsquelle fuer die Tests (Zaehler statt Zufall). */
|
||||||
|
function counter(start = 0): RandomFill {
|
||||||
|
let n = start;
|
||||||
|
return (array) => {
|
||||||
|
for (let i = 0; i < array.length; i += 1) {
|
||||||
|
n = (n * 1103515245 + 12345) >>> 0;
|
||||||
|
array[i] = n;
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generatePassword', () => {
|
||||||
|
it('mindestens 20 Zeichen, von der Mindestlaenge der Nextcloud gehoben, bei 64 gedeckelt', () => {
|
||||||
|
expect(generatePassword(null, counter(1))).toHaveLength(20);
|
||||||
|
expect(generatePassword(10, counter(2))).toHaveLength(20);
|
||||||
|
expect(generatePassword(32, counter(3))).toHaveLength(32);
|
||||||
|
expect(generatePassword(500, counter(4))).toHaveLength(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enthaelt Gross-, Kleinbuchstaben, Ziffer und Sonderzeichen, nie aehnliche Zeichen', () => {
|
||||||
|
for (let seed = 0; seed < 50; seed += 1) {
|
||||||
|
const pw = generatePassword(null, counter(seed));
|
||||||
|
expect(pw).toMatch(/[A-Z]/);
|
||||||
|
expect(pw).toMatch(/[a-z]/);
|
||||||
|
expect(pw).toMatch(/[0-9]/);
|
||||||
|
expect(pw).toMatch(/[^A-Za-z0-9]/);
|
||||||
|
expect(pw).not.toMatch(/[0O1lIo]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit der echten Zufallsquelle: zwei Aufrufe ergeben verschiedene Werte', () => {
|
||||||
|
expect(generatePassword(null)).not.toBe(generatePassword(null));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { NcEntry, NcSharePolicy } from '@/lib/nextcloud-files-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Hilfen fuer das Teilen (quick-261009-dkv): was der Dialog anbietet, wird hier aus dem
|
||||||
|
* Eintrag und den Freigaberegeln der Nextcloud abgeleitet. Die API prueft dieselben Regeln
|
||||||
|
* verbindlich nach; der Browser spiegelt sie nur, damit der Benutzer nie eine Auswahl trifft,
|
||||||
|
* die abgelehnt wuerde.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ShareAccessChoice = 'view' | 'edit' | 'upload';
|
||||||
|
export type ShareAudience = 'user' | 'group' | 'link';
|
||||||
|
|
||||||
|
/** Das, was geteilt wird: Pfad, Name, Art und ob der Eintrag selbst beschreibbar ist. */
|
||||||
|
export interface ShareTarget {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
mime: string | null;
|
||||||
|
writable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nextcloud-Buchstaben W (schreiben), C und K (anlegen): nur dann ist "Bearbeiten" moeglich. */
|
||||||
|
export function targetFromEntry(
|
||||||
|
entry: Pick<NcEntry, 'path' | 'name' | 'type' | 'mime' | 'permissions'>,
|
||||||
|
): ShareTarget {
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
name: entry.name,
|
||||||
|
type: entry.type,
|
||||||
|
mime: entry.mime,
|
||||||
|
writable: /[WCK]/.test(entry.permissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungen, die fuer diesen Eintrag und diese Empfaengerart angeboten werden. Personen
|
||||||
|
* und Gruppen: Ansehen, bei beschreibbarem Eintrag auch Bearbeiten. Links zusaetzlich nur,
|
||||||
|
* wenn die Nextcloud oeffentliches Hochladen erlaubt; "Nur hochladen" nur bei Ordnern.
|
||||||
|
*/
|
||||||
|
export function accessOptions(
|
||||||
|
target: ShareTarget,
|
||||||
|
audience: ShareAudience,
|
||||||
|
policy: NcSharePolicy | null,
|
||||||
|
): ShareAccessChoice[] {
|
||||||
|
const options: ShareAccessChoice[] = ['view'];
|
||||||
|
if (audience === 'link') {
|
||||||
|
if (!policy?.links.uploadAllowed) return options;
|
||||||
|
if (target.writable) options.push('edit');
|
||||||
|
if (target.type === 'folder') options.push('upload');
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (target.writable) options.push('edit');
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Passwort erzeugen --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ohne Zeichen, die sich ähneln (0 O o, 1 l I). */
|
||||||
|
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const LOWER = 'abcdefghijkmnpqrstuvwxyz';
|
||||||
|
const DIGITS = '23456789';
|
||||||
|
const SPECIAL = '!#$%&*+-=?@_';
|
||||||
|
const MIN_GENERATED = 20;
|
||||||
|
const MAX_GENERATED = 64;
|
||||||
|
|
||||||
|
export type RandomFill = (array: Uint32Array) => Uint32Array;
|
||||||
|
|
||||||
|
const cryptoFill: RandomFill = (array) => crypto.getRandomValues(array);
|
||||||
|
|
||||||
|
/** Gleichverteilte Zufallszahl in [0, max) ohne Modulo-Verzerrung (Rejection Sampling). */
|
||||||
|
function randomBelow(max: number, fill: RandomFill): number {
|
||||||
|
const limit = Math.floor(0x1_0000_0000 / max) * max;
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
for (;;) {
|
||||||
|
fill(buffer);
|
||||||
|
if (buffer[0] < limit) return buffer[0] % max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(chars: string, fill: RandomFill): string {
|
||||||
|
return chars[randomBelow(chars.length, fill)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zufallspasswort aus der Betriebssystem-Zufallsquelle: mindestens 20 Zeichen (oder die
|
||||||
|
* Mindestlaenge der Nextcloud, hoechstens 64), je mindestens ein Gross- und Kleinbuchstabe,
|
||||||
|
* eine Ziffer und ein Sonderzeichen. Nextcloud prueft das Passwort trotzdem selbst.
|
||||||
|
*/
|
||||||
|
export function generatePassword(minLength: number | null, fill: RandomFill = cryptoFill): string {
|
||||||
|
const length = Math.min(MAX_GENERATED, Math.max(MIN_GENERATED, minLength ?? 0));
|
||||||
|
const all = UPPER + LOWER + DIGITS + SPECIAL;
|
||||||
|
const chars = [pick(UPPER, fill), pick(LOWER, fill), pick(DIGITS, fill), pick(SPECIAL, fill)];
|
||||||
|
while (chars.length < length) chars.push(pick(all, fill));
|
||||||
|
// Fisher-Yates, damit die vier Pflichtzeichen nicht immer vorn stehen.
|
||||||
|
for (let i = chars.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = randomBelow(i + 1, fill);
|
||||||
|
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ import {
|
|||||||
checkDownload,
|
checkDownload,
|
||||||
checkZip,
|
checkZip,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
createShare,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
|
deleteShare,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
|
getSharePolicy,
|
||||||
listFolder,
|
listFolder,
|
||||||
|
listSharesForPath,
|
||||||
moveEntry,
|
moveEntry,
|
||||||
NextcloudFilesRequestError,
|
NextcloudFilesRequestError,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
|
searchSharees,
|
||||||
|
updateShare,
|
||||||
ZIP_MAX_NAMES,
|
ZIP_MAX_NAMES,
|
||||||
zipFormFields,
|
zipFormFields,
|
||||||
zipPostUrl,
|
zipPostUrl,
|
||||||
@@ -150,3 +156,74 @@ describe('nextcloud-files-api — Herunterladen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('nextcloud-files-api — Teilen', () => {
|
||||||
|
it('getSharePolicy: GET /shares/policy', async () => {
|
||||||
|
const fetchMock = mockFetch(200, { enabled: true });
|
||||||
|
await getSharePolicy();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${API}/modules/nextcloud-files/shares/policy`);
|
||||||
|
expect(init.method).toBe('GET');
|
||||||
|
expect(init.credentials).toBe('include');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listSharesForPath codiert den Pfad nur in der Query', async () => {
|
||||||
|
const fetchMock = mockFetch(200, {
|
||||||
|
path: '/Ärger 100%',
|
||||||
|
shares: [],
|
||||||
|
hidden: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
await listSharesForPath('/Ärger 100%');
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${API}/modules/nextcloud-files/shares/by-path?path=%2F%C3%84rger%20100%25`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchSharees sendet Begriff und Art in der Query', async () => {
|
||||||
|
const fetchMock = mockFetch(200, { sharees: [] });
|
||||||
|
await searchSharees('ben', 'folder');
|
||||||
|
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${API}/modules/nextcloud-files/sharees?term=ben&itemType=folder`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createShare sendet genau die Eingabe als JSON (nie eine Maske)', async () => {
|
||||||
|
const fetchMock = mockFetch(201, { id: '1' });
|
||||||
|
await createShare({ path: '/P', kind: 'user', shareWith: 'ben', access: 'view' });
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${API}/modules/nextcloud-files/shares`);
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.body).toBe('{"path":"/P","kind":"user","shareWith":"ben","access":"view"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateShare und deleteShare tragen die Kennung im Pfad', async () => {
|
||||||
|
const put = mockFetch(200, { id: '17' });
|
||||||
|
await updateShare('17', { access: 'edit' });
|
||||||
|
const [putUrl, putInit] = put.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(putUrl).toBe(`${API}/modules/nextcloud-files/shares/17`);
|
||||||
|
expect(putInit.method).toBe('PUT');
|
||||||
|
expect(putInit.body).toBe('{"access":"edit"}');
|
||||||
|
|
||||||
|
const del = mockFetch(200, { deleted: true });
|
||||||
|
await deleteShare('17');
|
||||||
|
const [delUrl, delInit] = del.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(delUrl).toBe(`${API}/modules/nextcloud-files/shares/17`);
|
||||||
|
expect(delInit.method).toBe('DELETE');
|
||||||
|
expect(delInit.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler der Freigaben tragen ncMessage in extra', async () => {
|
||||||
|
mockFetch(422, {
|
||||||
|
code: 'shareRejected',
|
||||||
|
message: 'Nextcloud hat diese Freigabe abgelehnt.',
|
||||||
|
ncMessage: 'Nein',
|
||||||
|
});
|
||||||
|
const err = await createShare({
|
||||||
|
path: '/P',
|
||||||
|
kind: 'user',
|
||||||
|
shareWith: 'b',
|
||||||
|
access: 'view',
|
||||||
|
}).catch((e) => e);
|
||||||
|
expect(err.code).toBe('shareRejected');
|
||||||
|
expect(err.extra).toEqual({ ncMessage: 'Nein' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ export interface NcEntry {
|
|||||||
permissions: string;
|
permissions: string;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
|
/** Freigabearten des Eintrags (0 Person, 1 Gruppe, 3 Link ...); leer = nicht geteilt. */
|
||||||
|
shareTypes: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NcQuota {
|
export interface NcQuota {
|
||||||
@@ -315,3 +317,111 @@ export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: t
|
|||||||
json: { dir, names, check: true },
|
json: { dir, names, check: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Teilen (quick-261009-dkv) -------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type NcShareKind = 'user' | 'group' | 'link';
|
||||||
|
/** `custom`: eine Berechtigung, die sich nicht auf Ansehen/Bearbeiten/Hochladen abbilden laesst. */
|
||||||
|
export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom';
|
||||||
|
|
||||||
|
export interface NcShare {
|
||||||
|
id: string;
|
||||||
|
kind: NcShareKind;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
itemType: 'file' | 'folder';
|
||||||
|
mime: string | null;
|
||||||
|
itemWritable: boolean;
|
||||||
|
permissions: number;
|
||||||
|
access: NcShareAccess;
|
||||||
|
shareWith: string | null;
|
||||||
|
shareWithName: string | null;
|
||||||
|
ownerId: string | null;
|
||||||
|
ownerName: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
/** `YYYY-MM-DD` oder null. */
|
||||||
|
expiration: string | null;
|
||||||
|
label: string;
|
||||||
|
/** Nur bei eigenen Links; Tessera ruft sie nie auf. */
|
||||||
|
url: string | null;
|
||||||
|
hasPassword: boolean;
|
||||||
|
target: string;
|
||||||
|
sharedAt: string | null;
|
||||||
|
pending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NcSharee {
|
||||||
|
kind: 'user' | 'group';
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freigaberegeln der Nextcloud fuer den angemeldeten Benutzer (frisch gelesen). */
|
||||||
|
export interface NcSharePolicy {
|
||||||
|
enabled: boolean;
|
||||||
|
groupsEnabled: boolean;
|
||||||
|
links: {
|
||||||
|
enabled: boolean;
|
||||||
|
passwordRequired: boolean;
|
||||||
|
passwordSuggested: boolean;
|
||||||
|
expiryDefaultDays: number | null;
|
||||||
|
expiryEnforced: boolean;
|
||||||
|
uploadAllowed: boolean;
|
||||||
|
multipleLinks: boolean;
|
||||||
|
};
|
||||||
|
internalExpiry: { defaultDays: number | null; enforced: boolean };
|
||||||
|
minSearchLength: number;
|
||||||
|
passwordMinLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NcShareList {
|
||||||
|
path: string;
|
||||||
|
shares: NcShare[];
|
||||||
|
/** Freigaben anderer Arten (E-Mail, Server ...), nur als Zahl. */
|
||||||
|
hidden: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShareInput {
|
||||||
|
path: string;
|
||||||
|
kind: 'user' | 'group';
|
||||||
|
shareWith: string;
|
||||||
|
access: 'view' | 'edit' | 'upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateShareInput {
|
||||||
|
access?: 'view' | 'edit' | 'upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSharePolicy(): Promise<NcSharePolicy> {
|
||||||
|
return request<NcSharePolicy>('/shares/policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freigaben eines Eintrags; der Pfad geht nur in der Query. */
|
||||||
|
export function listSharesForPath(path: string): Promise<NcShareList> {
|
||||||
|
return request<NcShareList>(`/shares/by-path?path=${encodeURIComponent(path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personen und Gruppen, die die Nextcloud zum Suchbegriff kennt. */
|
||||||
|
export function searchSharees(
|
||||||
|
term: string,
|
||||||
|
itemType: 'file' | 'folder',
|
||||||
|
): Promise<{ sharees: NcSharee[] }> {
|
||||||
|
return request<{ sharees: NcSharee[] }>(
|
||||||
|
`/sharees?term=${encodeURIComponent(term)}&itemType=${encodeURIComponent(itemType)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShare(input: CreateShareInput): Promise<NcShare> {
|
||||||
|
return request<NcShare>('/shares', { method: 'POST', json: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShare(id: string, input: UpdateShareInput): Promise<NcShare> {
|
||||||
|
return request<NcShare>(`/shares/${encodeURIComponent(id)}`, { method: 'PUT', json: input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteShare(id: string): Promise<{ deleted: true }> {
|
||||||
|
return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -2471,6 +2471,7 @@
|
|||||||
"downloadZip": "Als ZIP herunterladen",
|
"downloadZip": "Als ZIP herunterladen",
|
||||||
"rename": "Umbenennen",
|
"rename": "Umbenennen",
|
||||||
"move": "Verschieben",
|
"move": "Verschieben",
|
||||||
|
"share": "Teilen",
|
||||||
"openInNextcloud": "In Nextcloud öffnen",
|
"openInNextcloud": "In Nextcloud öffnen",
|
||||||
"delete": "Löschen"
|
"delete": "Löschen"
|
||||||
},
|
},
|
||||||
@@ -2572,6 +2573,38 @@
|
|||||||
"skip": "Überspringen"
|
"skip": "Überspringen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "„{name}“ teilen",
|
||||||
|
"loading": "Freigaben werden geladen …",
|
||||||
|
"done": "Fertig",
|
||||||
|
"hidden": "{count, plural, one {Eine weitere Freigabe, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.} other {# weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.}}",
|
||||||
|
"people": {
|
||||||
|
"title": "Personen und Gruppen",
|
||||||
|
"searchLabel": "Person oder Gruppe suchen",
|
||||||
|
"searchPlaceholder": "Name eingeben",
|
||||||
|
"newAccess": "Berechtigung für die nächste Freigabe",
|
||||||
|
"searching": "Suche läuft …",
|
||||||
|
"minChars": "Geben Sie mindestens {count} Zeichen ein.",
|
||||||
|
"noResults": "Niemand gefunden.",
|
||||||
|
"results": "Suchergebnisse",
|
||||||
|
"group": "Gruppe",
|
||||||
|
"alreadyShared": "bereits geteilt",
|
||||||
|
"empty": "Noch mit niemandem geteilt.",
|
||||||
|
"accessFor": "Berechtigung für {name}",
|
||||||
|
"remove": "Freigabe für {name} entfernen",
|
||||||
|
"expires": "gültig bis {date}"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"view": "Ansehen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"upload": "Nur hochladen",
|
||||||
|
"custom": "Eigene Berechtigung"
|
||||||
|
},
|
||||||
|
"indicator": {
|
||||||
|
"outgoing": "Freigaben von „{name}“ öffnen",
|
||||||
|
"incoming": "Mit Ihnen geteilt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"codes": {
|
"codes": {
|
||||||
"lengthRequired": "Die Größe der Datei ließ sich nicht bestimmen. Bitte versuchen Sie es erneut.",
|
"lengthRequired": "Die Größe der Datei ließ sich nicht bestimmen. Bitte versuchen Sie es erneut.",
|
||||||
"chunkTooLarge": "Ein Teilstück der Datei war zu groß. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
|
"chunkTooLarge": "Ein Teilstück der Datei war zu groß. Bitte laden Sie die Seite neu und versuchen Sie es erneut.",
|
||||||
@@ -2590,7 +2623,21 @@
|
|||||||
"invalidPath": "Dieser Name ist nicht erlaubt.",
|
"invalidPath": "Dieser Name ist nicht erlaubt.",
|
||||||
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
|
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
|
||||||
"generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut.",
|
"generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut.",
|
||||||
"selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter."
|
"selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter.",
|
||||||
|
"sharingDisabled": "Teilen ist in Ihrer Nextcloud ausgeschaltet.",
|
||||||
|
"sharingDisabledGroups": "Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.",
|
||||||
|
"linkSharingDisabled": "Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.",
|
||||||
|
"shareAccessInvalid": "Diese Berechtigung ist für diesen Eintrag nicht möglich.",
|
||||||
|
"shareRecipientInvalid": "Diese Person oder Gruppe kennt Ihre Nextcloud nicht.",
|
||||||
|
"shareAlreadyExists": "Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.",
|
||||||
|
"sharePasswordRequired": "Ihre Nextcloud verlangt für Links ein Passwort.",
|
||||||
|
"sharePasswordRejected": "Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.",
|
||||||
|
"shareExpiryRequired": "Ihre Nextcloud verlangt für Links ein Ablaufdatum.",
|
||||||
|
"shareExpiryInvalid": "Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.",
|
||||||
|
"shareRejected": "Nextcloud hat diese Freigabe abgelehnt.",
|
||||||
|
"shareNotFound": "Diese Freigabe gibt es nicht mehr.",
|
||||||
|
"tooManyShares": "Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.",
|
||||||
|
"ncDetail": "Meldung der Nextcloud: {detail}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2471,6 +2471,7 @@
|
|||||||
"downloadZip": "Download as ZIP",
|
"downloadZip": "Download as ZIP",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"move": "Move",
|
"move": "Move",
|
||||||
|
"share": "Share",
|
||||||
"openInNextcloud": "Open in Nextcloud",
|
"openInNextcloud": "Open in Nextcloud",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
@@ -2572,6 +2573,38 @@
|
|||||||
"skip": "Skip"
|
"skip": "Skip"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Share “{name}”",
|
||||||
|
"loading": "Loading shares …",
|
||||||
|
"done": "Done",
|
||||||
|
"hidden": "{count, plural, one {One more share, for example by email, is only visible in Nextcloud.} other {# more shares, for example by email, are only visible in Nextcloud.}}",
|
||||||
|
"people": {
|
||||||
|
"title": "People and groups",
|
||||||
|
"searchLabel": "Search for a person or group",
|
||||||
|
"searchPlaceholder": "Enter a name",
|
||||||
|
"newAccess": "Permission for the next share",
|
||||||
|
"searching": "Searching …",
|
||||||
|
"minChars": "Enter at least {count} characters.",
|
||||||
|
"noResults": "Nobody found.",
|
||||||
|
"results": "Search results",
|
||||||
|
"group": "Group",
|
||||||
|
"alreadyShared": "already shared",
|
||||||
|
"empty": "Not shared with anyone yet.",
|
||||||
|
"accessFor": "Permission for {name}",
|
||||||
|
"remove": "Remove share for {name}",
|
||||||
|
"expires": "valid until {date}"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"view": "View",
|
||||||
|
"edit": "Edit",
|
||||||
|
"upload": "Upload only",
|
||||||
|
"custom": "Custom permission"
|
||||||
|
},
|
||||||
|
"indicator": {
|
||||||
|
"outgoing": "Open shares of “{name}”",
|
||||||
|
"incoming": "Shared with you"
|
||||||
|
}
|
||||||
|
},
|
||||||
"codes": {
|
"codes": {
|
||||||
"lengthRequired": "The size of the file could not be determined. Please try again.",
|
"lengthRequired": "The size of the file could not be determined. Please try again.",
|
||||||
"chunkTooLarge": "A part of the file was too large. Please reload the page and try again.",
|
"chunkTooLarge": "A part of the file was too large. Please reload the page and try again.",
|
||||||
@@ -2590,7 +2623,21 @@
|
|||||||
"invalidPath": "This name is not allowed.",
|
"invalidPath": "This name is not allowed.",
|
||||||
"nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.",
|
"nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.",
|
||||||
"generic": "That did not work. Please try again.",
|
"generic": "That did not work. Please try again.",
|
||||||
"selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder."
|
"selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder.",
|
||||||
|
"sharingDisabled": "Sharing is switched off in your Nextcloud.",
|
||||||
|
"sharingDisabledGroups": "Sharing with groups is switched off in your Nextcloud.",
|
||||||
|
"linkSharingDisabled": "Public links are switched off in your Nextcloud.",
|
||||||
|
"shareAccessInvalid": "This permission is not possible for this item.",
|
||||||
|
"shareRecipientInvalid": "Your Nextcloud does not know this person or group.",
|
||||||
|
"shareAlreadyExists": "The item is already shared this way. Change the existing share instead.",
|
||||||
|
"sharePasswordRequired": "Your Nextcloud requires a password for links.",
|
||||||
|
"sharePasswordRejected": "Nextcloud rejects this password. Please choose a longer or less common password.",
|
||||||
|
"shareExpiryRequired": "Your Nextcloud requires an expiry date for links.",
|
||||||
|
"shareExpiryInvalid": "Your Nextcloud does not allow this expiry date. It must not be in the past or after the latest permitted date.",
|
||||||
|
"shareRejected": "Nextcloud rejected this share.",
|
||||||
|
"shareNotFound": "This share no longer exists.",
|
||||||
|
"tooManyShares": "You have created many shares in a short time. Please wait {minutes, plural, one {# minute} other {# minutes}}.",
|
||||||
|
"ncDetail": "Message from Nextcloud: {detail}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,4 +239,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
// quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“
|
// quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“
|
||||||
'ausstellen',
|
'ausstellen',
|
||||||
'vergisst',
|
'vergisst',
|
||||||
|
// quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“
|
||||||
|
'Suchergebnisse',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user