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:
@@ -27,6 +27,8 @@ const mockDelete = vi.fn();
|
||||
const mockUpload = vi.fn();
|
||||
const mockCheckDownload = vi.fn();
|
||||
const mockCheckZip = vi.fn();
|
||||
const mockPolicy = vi.fn();
|
||||
const mockSharesForPath = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||
@@ -38,6 +40,8 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
deleteEntry: (...a: unknown[]) => mockDelete(...a),
|
||||
checkDownload: (...a: unknown[]) => mockCheckDownload(...a),
|
||||
checkZip: (...a: unknown[]) => mockCheckZip(...a),
|
||||
getSharePolicy: (...a: unknown[]) => mockPolicy(...a),
|
||||
listSharesForPath: (...a: unknown[]) => mockSharesForPath(...a),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/nextcloud-files-upload', () => ({
|
||||
@@ -61,6 +65,7 @@ function entry(path: string, over: Partial<NcEntry> = {}): NcEntry {
|
||||
permissions: 'RGDNVW',
|
||||
hasPreview: false,
|
||||
favorite: false,
|
||||
shareTypes: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -117,6 +122,30 @@ beforeEach(() => {
|
||||
mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 });
|
||||
mockCheckDownload.mockReset().mockResolvedValue({ ok: true });
|
||||
mockCheckZip.mockReset().mockResolvedValue({ ok: true });
|
||||
mockPolicy.mockReset().mockResolvedValue({
|
||||
enabled: true,
|
||||
groupsEnabled: true,
|
||||
links: {
|
||||
enabled: true,
|
||||
passwordRequired: false,
|
||||
passwordSuggested: false,
|
||||
expiryDefaultDays: null,
|
||||
expiryEnforced: false,
|
||||
uploadAllowed: true,
|
||||
multipleLinks: true,
|
||||
},
|
||||
internalExpiry: { defaultDays: null, enforced: false },
|
||||
minSearchLength: 0,
|
||||
passwordMinLength: 10,
|
||||
});
|
||||
mockSharesForPath
|
||||
.mockReset()
|
||||
.mockImplementation(async (p: string) => ({
|
||||
path: p,
|
||||
shares: [],
|
||||
hidden: 0,
|
||||
truncated: false,
|
||||
}));
|
||||
onExpired = vi.fn();
|
||||
clicked = [];
|
||||
submitted = [];
|
||||
@@ -379,6 +408,7 @@ describe('FileBrowser — Liste', () => {
|
||||
'Herunterladen',
|
||||
'Umbenennen',
|
||||
'Verschieben',
|
||||
'Teilen',
|
||||
'In Nextcloud öffnen',
|
||||
'Löschen',
|
||||
]);
|
||||
@@ -634,3 +664,100 @@ describe('FileBrowser — Vorschaubilder (WR-03)', () => {
|
||||
expect(mockList.mock.calls.length - callsBefore).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileBrowser — Teilen (quick-261009-dkv)', () => {
|
||||
it('das Menue bietet Teilen nur bei Eintraegen mit dem Buchstaben R und oeffnet den Dialog', async () => {
|
||||
mockList.mockImplementation(async (p: string) =>
|
||||
listing(
|
||||
p,
|
||||
p === '/'
|
||||
? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'GVW' })]
|
||||
: (FOLDERS[p] ?? []),
|
||||
),
|
||||
);
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Fremd.txt' }));
|
||||
expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull();
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '„Projekte“ teilen' });
|
||||
expect(within(dialog).getByRole('heading', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||
await waitFor(() => expect(mockSharesForPath).toHaveBeenCalledWith('/Projekte'));
|
||||
});
|
||||
|
||||
it('ist Teilen ausgeschaltet (sharingEnabled false), fehlen Menueeintrag und Knopf am Kennzeichen', async () => {
|
||||
mockList.mockImplementation(async (p: string) =>
|
||||
listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [0] })] : []),
|
||||
);
|
||||
render(<FileBrowser serverUrl={null} onExpired={onExpired} sharingEnabled={false} />);
|
||||
await screen.findByRole('row', { name: 'Projekte' });
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||
expect(screen.queryByRole('menuitem', { name: 'Teilen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Freigaben von „Projekte“ öffnen' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ein geteilter Eintrag traegt den Knopf am Kennzeichen, der den Dialog oeffnet', async () => {
|
||||
mockList.mockImplementation(async (p: string) =>
|
||||
listing(
|
||||
p,
|
||||
p === '/'
|
||||
? [folder('/Projekte', { shareTypes: [0] }), entry('/Bericht.pdf', { shareTypes: [3] })]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
await mount();
|
||||
const row = screen.getByRole('row', { name: 'Projekte' });
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' }));
|
||||
expect(await screen.findByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||
// Das Kennzeichen waehlt die Zeile nicht aus.
|
||||
expect(row.getAttribute('aria-selected')).toBe('false');
|
||||
// Der zweite Eintrag hat ebenfalls ein Kennzeichen
|
||||
expect(
|
||||
within(screen.getByRole('row', { name: 'Bericht.pdf' })).getByRole('button', {
|
||||
name: 'Freigaben von „Bericht.pdf“ öffnen',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ein Eintrag mit S in den Berechtigungen zeigt die Markierung "Mit Ihnen geteilt"', async () => {
|
||||
mockList.mockImplementation(async (p: string) =>
|
||||
listing(
|
||||
p,
|
||||
p === '/' ? [folder('/Projekte'), entry('/Fremd.txt', { permissions: 'SGVW' })] : [],
|
||||
),
|
||||
);
|
||||
await mount();
|
||||
const row = screen.getByRole('row', { name: 'Fremd.txt' });
|
||||
expect(within(row).getByTestId('share-incoming').getAttribute('title')).toBe(
|
||||
'Mit Ihnen geteilt',
|
||||
);
|
||||
expect(within(row).getByText('Mit Ihnen geteilt')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('row', { name: 'Projekte' })).queryByTestId('share-incoming'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('im Raster erscheint das Kennzeichen ebenfalls', async () => {
|
||||
mockList.mockImplementation(async (p: string) =>
|
||||
listing(p, p === '/' ? [folder('/Projekte', { shareTypes: [1] })] : []),
|
||||
);
|
||||
await mount();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Raster' }));
|
||||
const grid = await screen.findByTestId('nc-files-grid');
|
||||
expect(
|
||||
within(grid).getByRole('button', { name: 'Freigaben von „Projekte“ öffnen' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ein abgelaufener Zugang im Dialog fuehrt zurueck zum Anmeldebildschirm', async () => {
|
||||
mockPolicy.mockRejectedValue(
|
||||
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||
);
|
||||
await mount();
|
||||
fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Teilen' }));
|
||||
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { collectDrop, hasFiles, snapshotDrop } from '@/components/nextcloud-file
|
||||
import { type ErrorLike, errorText, toErrorLike } from '@/components/nextcloud-files/error-text';
|
||||
import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-files/paths';
|
||||
import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection';
|
||||
import { type ShareTarget, targetFromEntry } from '@/components/nextcloud-files/share-policy';
|
||||
import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers';
|
||||
import {
|
||||
checkDownload,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
OpenIcon,
|
||||
PencilIcon,
|
||||
RetryIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from './icons';
|
||||
@@ -42,6 +44,7 @@ import { MoveDialog } from './MoveDialog';
|
||||
import { NameDialog } from './NameDialog';
|
||||
import { QuotaMeter } from './QuotaMeter';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { ShareDialog } from './ShareDialog';
|
||||
import { type SortDir, type SortKey, Toolbar, type ViewMode } from './Toolbar';
|
||||
import { TransferBar } from './TransferBar';
|
||||
|
||||
@@ -52,7 +55,8 @@ type DialogState =
|
||||
| { kind: 'newFolder' }
|
||||
| { kind: 'rename'; entry: NcEntry }
|
||||
| { kind: 'move'; entries: NcEntry[] }
|
||||
| { kind: 'delete'; entries: NcEntry[] };
|
||||
| { kind: 'delete'; entries: NcEntry[] }
|
||||
| { kind: 'share'; target: ShareTarget };
|
||||
|
||||
interface MenuState {
|
||||
entry: NcEntry;
|
||||
@@ -143,11 +147,14 @@ function isTypingTarget(el: EventTarget | null): boolean {
|
||||
export function FileBrowser({
|
||||
serverUrl,
|
||||
onExpired,
|
||||
sharingEnabled = true,
|
||||
}: {
|
||||
/** Adresse der Nextcloud fuer "In Nextcloud öffnen". */
|
||||
serverUrl: string | null;
|
||||
/** Die Verbindung ist abgelaufen: die Seite zeigt wieder den Anmeldebildschirm. */
|
||||
onExpired: () => void;
|
||||
/** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */
|
||||
sharingEnabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('nextcloudFiles.browser');
|
||||
const tCodes = useTranslations('nextcloudFiles.codes');
|
||||
@@ -672,6 +679,15 @@ export function FileBrowser({
|
||||
onSelect: () => setDialog({ kind: 'move', entries: [entry] }),
|
||||
},
|
||||
);
|
||||
// Nur Eintraege, die Nextcloud als teilbar kennzeichnet (Buchstabe R).
|
||||
if (sharingEnabled && entry.permissions.includes('R')) {
|
||||
actions.push({
|
||||
id: 'share',
|
||||
label: t('menu.share'),
|
||||
icon: <ShareIcon />,
|
||||
onSelect: () => setDialog({ kind: 'share', target: targetFromEntry(entry) }),
|
||||
});
|
||||
}
|
||||
if (serverUrl && entry.fileId) {
|
||||
actions.push({
|
||||
id: 'openInNextcloud',
|
||||
@@ -856,6 +872,9 @@ export function FileBrowser({
|
||||
onToggle,
|
||||
onMenu: openMenu,
|
||||
onFocusItem: setFocusIndex,
|
||||
onShare: sharingEnabled
|
||||
? (entry: NcEntry) => setDialog({ kind: 'share', target: targetFromEntry(entry) })
|
||||
: undefined,
|
||||
onPreviewError,
|
||||
};
|
||||
|
||||
@@ -1072,6 +1091,14 @@ export function FileBrowser({
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog?.kind === 'share' && (
|
||||
<ShareDialog
|
||||
target={dialog.target}
|
||||
onChanged={() => void load(pathRef.current, { quiet: true })}
|
||||
onExpired={() => onExpiredRef.current()}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fileTypeOf } from '@/components/nextcloud-files/file-types';
|
||||
import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api';
|
||||
import type { EntryViewProps } from './FileList';
|
||||
import { CheckIcon, MoreIcon } from './icons';
|
||||
import { ShareIndicator } from './ShareIndicator';
|
||||
import { TypeTile } from './TypeTile';
|
||||
|
||||
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
|
||||
@@ -54,6 +55,7 @@ export function FileGrid({
|
||||
onToggle,
|
||||
onMenu,
|
||||
onFocusItem,
|
||||
onShare,
|
||||
onPreviewError,
|
||||
}: EntryViewProps) {
|
||||
const t = useTranslations('nextcloudFiles.browser');
|
||||
@@ -169,12 +171,18 @@ export function FileGrid({
|
||||
{entry.name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="mt-0.5 text-xs tabular-nums text-muted-foreground"
|
||||
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
||||
>
|
||||
{formatSize(entry.size, locale)}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<p
|
||||
className="text-xs tabular-nums text-muted-foreground"
|
||||
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
||||
>
|
||||
{formatSize(entry.size, locale)}
|
||||
</p>
|
||||
<ShareIndicator
|
||||
entry={entry}
|
||||
onShare={onShare ? () => onShare(entry) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { downloadUrl, type NcEntry } from '@/lib/nextcloud-files-api';
|
||||
import { ChevronDownIcon, ChevronUpIcon, MoreIcon } from './icons';
|
||||
import type { SortDir, SortKey } from './Toolbar';
|
||||
import { ShareIndicator } from './ShareIndicator';
|
||||
import { TypeTile } from './TypeTile';
|
||||
|
||||
/** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */
|
||||
@@ -29,6 +30,8 @@ export interface EntryViewProps {
|
||||
onToggle: (entry: NcEntry, index: number) => void;
|
||||
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
|
||||
onFocusItem: (index: number) => void;
|
||||
/** Teilen-Dialog eines Eintrags oeffnen (Kennzeichen); fehlt sie, ist Teilen ausgeschaltet. */
|
||||
onShare?: (entry: NcEntry) => void;
|
||||
/**
|
||||
* Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die
|
||||
* Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck.
|
||||
@@ -55,6 +58,7 @@ export function FileList({
|
||||
onToggle,
|
||||
onMenu,
|
||||
onFocusItem,
|
||||
onShare,
|
||||
onPreviewError,
|
||||
sort,
|
||||
onSortChange,
|
||||
@@ -221,6 +225,10 @@ export function FileList({
|
||||
{entry.name}
|
||||
</a>
|
||||
)}
|
||||
<ShareIndicator
|
||||
entry={entry}
|
||||
onShare={onShare ? () => onShare(entry) : undefined}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-0 pr-1 text-right text-[13px] tabular-nums text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ShareTarget } from '@/components/nextcloud-files/share-policy';
|
||||
import {
|
||||
type NcShare,
|
||||
type NcSharee,
|
||||
type NcSharePolicy,
|
||||
NextcloudFilesRequestError,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { ShareDialog } from './ShareDialog';
|
||||
|
||||
const mockPolicy = vi.fn();
|
||||
const mockList = vi.fn();
|
||||
const mockSearch = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
||||
return {
|
||||
...actual,
|
||||
getSharePolicy: (...a: unknown[]) => mockPolicy(...a),
|
||||
listSharesForPath: (...a: unknown[]) => mockList(...a),
|
||||
searchSharees: (...a: unknown[]) => mockSearch(...a),
|
||||
createShare: (...a: unknown[]) => mockCreate(...a),
|
||||
updateShare: (...a: unknown[]) => mockUpdate(...a),
|
||||
deleteShare: (...a: unknown[]) => mockDelete(...a),
|
||||
};
|
||||
});
|
||||
|
||||
const POLICY: NcSharePolicy = {
|
||||
enabled: true,
|
||||
groupsEnabled: true,
|
||||
links: {
|
||||
enabled: true,
|
||||
passwordRequired: false,
|
||||
passwordSuggested: false,
|
||||
expiryDefaultDays: null,
|
||||
expiryEnforced: false,
|
||||
uploadAllowed: true,
|
||||
multipleLinks: true,
|
||||
},
|
||||
internalExpiry: { defaultDays: null, enforced: false },
|
||||
minSearchLength: 0,
|
||||
passwordMinLength: 10,
|
||||
};
|
||||
|
||||
const FOLDER: ShareTarget = {
|
||||
path: '/Projekte',
|
||||
name: 'Projekte',
|
||||
type: 'folder',
|
||||
mime: null,
|
||||
writable: true,
|
||||
};
|
||||
|
||||
function share(over: Partial<NcShare> = {}): NcShare {
|
||||
return {
|
||||
id: '17',
|
||||
kind: 'user',
|
||||
path: '/Projekte',
|
||||
name: 'Projekte',
|
||||
itemType: 'folder',
|
||||
mime: null,
|
||||
itemWritable: true,
|
||||
permissions: 1,
|
||||
access: 'view',
|
||||
shareWith: 'ben',
|
||||
shareWithName: 'Ben Beispiel',
|
||||
ownerId: 'anna',
|
||||
ownerName: 'Anna Müller',
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
expiration: null,
|
||||
label: '',
|
||||
url: null,
|
||||
hasPassword: false,
|
||||
target: '/Projekte',
|
||||
sharedAt: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const BEN: NcSharee = { kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' };
|
||||
const ZOE: NcSharee = { kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' };
|
||||
const TEAM: NcSharee = { kind: 'group', id: 'tessera-team', label: 'tessera-team', detail: null };
|
||||
|
||||
let onClose: ReturnType<typeof vi.fn>;
|
||||
let onChanged: ReturnType<typeof vi.fn>;
|
||||
let onExpired: ReturnType<typeof vi.fn>;
|
||||
|
||||
function mount(target: ShareTarget = FOLDER) {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
<ShareDialog target={target} onClose={onClose} onChanged={onChanged} onExpired={onExpired} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function ready() {
|
||||
return screen.findByRole('combobox', { name: 'Person oder Gruppe suchen' });
|
||||
}
|
||||
|
||||
function type(input: HTMLElement, value: string) {
|
||||
fireEvent.change(input, { target: { value } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onClose = vi.fn();
|
||||
onChanged = vi.fn();
|
||||
onExpired = vi.fn();
|
||||
mockPolicy.mockReset().mockResolvedValue(POLICY);
|
||||
mockList
|
||||
.mockReset()
|
||||
.mockResolvedValue({ path: '/Projekte', shares: [], hidden: 0, truncated: false });
|
||||
mockSearch.mockReset().mockResolvedValue({ sharees: [BEN, ZOE, TEAM] });
|
||||
mockCreate
|
||||
.mockReset()
|
||||
.mockImplementation(async (input: { shareWith: string; kind: string }) =>
|
||||
share({ id: '99', shareWith: input.shareWith, kind: input.kind as 'user' | 'group' }),
|
||||
);
|
||||
mockUpdate
|
||||
.mockReset()
|
||||
.mockImplementation(async (_id: string, input: { access: 'view' | 'edit' }) =>
|
||||
share({ access: input.access, permissions: input.access === 'edit' ? 15 : 1 }),
|
||||
);
|
||||
mockDelete.mockReset().mockResolvedValue({ deleted: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('ShareDialog — Personen und Gruppen', () => {
|
||||
it('zeigt die vorhandenen Freigaben mit Gruppenkennzeichen und Ablaufdatum', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
path: '/Projekte',
|
||||
shares: [
|
||||
share(),
|
||||
share({
|
||||
id: '18',
|
||||
kind: 'group',
|
||||
shareWith: 'tessera-team',
|
||||
shareWithName: 'tessera-team',
|
||||
expiration: '2026-12-31',
|
||||
}),
|
||||
],
|
||||
hidden: 2,
|
||||
truncated: false,
|
||||
});
|
||||
mount();
|
||||
await ready();
|
||||
expect(screen.getByText('Ben Beispiel')).toBeTruthy();
|
||||
const teamRow = screen.getByText('tessera-team').closest('li') as HTMLElement;
|
||||
expect(within(teamRow).getByText('Gruppe')).toBeTruthy();
|
||||
expect(within(teamRow).getByText('gültig bis 31.12.2026')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('2 weitere Freigaben, zum Beispiel per E-Mail, sehen Sie nur in Nextcloud.'),
|
||||
).toBeTruthy();
|
||||
expect(mockList).toHaveBeenCalledWith('/Projekte');
|
||||
});
|
||||
|
||||
it('tippen ruft die Suche einmal nach der Verzoegerung auf; Auswaehlen legt genau eine Freigabe an', async () => {
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'b');
|
||||
type(input, 'be');
|
||||
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
|
||||
expect(mockSearch).toHaveBeenCalledWith('be', 'folder');
|
||||
const list = await screen.findByRole('listbox', { name: 'Suchergebnisse' });
|
||||
expect(within(list).getAllByRole('option')).toHaveLength(3);
|
||||
fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ }));
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
path: '/Projekte',
|
||||
kind: 'user',
|
||||
shareWith: 'ben',
|
||||
access: 'view',
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }),
|
||||
).toBeTruthy();
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
expect((input as HTMLInputElement).value).toBe('');
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('die gewaehlte Berechtigung gilt fuer die naechste Freigabe; eine Gruppe wird als Gruppe angelegt', async () => {
|
||||
mount();
|
||||
const input = await ready();
|
||||
fireEvent.change(
|
||||
screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' }),
|
||||
{
|
||||
target: { value: 'edit' },
|
||||
},
|
||||
);
|
||||
type(input, 'tes');
|
||||
const list = await screen.findByRole('listbox');
|
||||
fireEvent.click(within(list).getByRole('option', { name: /tessera-team/ }));
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
path: '/Projekte',
|
||||
kind: 'group',
|
||||
shareWith: 'tessera-team',
|
||||
access: 'edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('ein bereits geteilter Empfaenger ist gesperrt und legt nichts an', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
path: '/Projekte',
|
||||
shares: [share()],
|
||||
hidden: 0,
|
||||
truncated: false,
|
||||
});
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'be');
|
||||
const list = await screen.findByRole('listbox');
|
||||
const option = within(list).getByRole('option', { name: /Ben Beispiel/ });
|
||||
expect(option.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(within(option).getByText('bereits geteilt')).toBeTruthy();
|
||||
fireEvent.click(option);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Pfeiltasten und Enter waehlen; Escape schliesst zuerst nur die Ergebnisliste', async () => {
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'be');
|
||||
await screen.findByRole('listbox');
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
||||
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||
const active = input.getAttribute('aria-activedescendant');
|
||||
expect(active).toBeTruthy();
|
||||
expect(document.getElementById(active as string)?.textContent).toContain('Zwei Faktor');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0]).toMatchObject({ shareWith: 'zoe' });
|
||||
});
|
||||
|
||||
it('ist die Gruppenfreigabe ausgeschaltet, erscheinen keine Gruppen in der Suche', async () => {
|
||||
mockPolicy.mockResolvedValue({ ...POLICY, groupsEnabled: false });
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'te');
|
||||
const list = await screen.findByRole('listbox');
|
||||
expect(within(list).queryByRole('option', { name: /tessera-team/ })).toBeNull();
|
||||
expect(within(list).getAllByRole('option')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ein Eintrag ohne Schreibrecht bietet nur "Ansehen"', async () => {
|
||||
mount({ ...FOLDER, writable: false });
|
||||
await ready();
|
||||
const select = screen.getByRole('combobox', { name: 'Berechtigung für die nächste Freigabe' });
|
||||
expect(
|
||||
within(select)
|
||||
.getAllByRole('option')
|
||||
.map((o) => o.textContent),
|
||||
).toEqual(['Ansehen']);
|
||||
});
|
||||
|
||||
it('Berechtigung einer Zeile aendern ruft updateShare mit der Auswahl, nie mit einer Maske', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
path: '/Projekte',
|
||||
shares: [share()],
|
||||
hidden: 0,
|
||||
truncated: false,
|
||||
});
|
||||
mount();
|
||||
await ready();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Berechtigung für Ben Beispiel' }), {
|
||||
target: { value: 'edit' },
|
||||
});
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate).toHaveBeenCalledWith('17', { access: 'edit' });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('combobox', {
|
||||
name: 'Berechtigung für Ben Beispiel',
|
||||
}) as HTMLSelectElement
|
||||
).value,
|
||||
).toBe('edit'),
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Entfernen ruft deleteShare und nimmt die Zeile heraus', async () => {
|
||||
mockList.mockResolvedValue({
|
||||
path: '/Projekte',
|
||||
shares: [share()],
|
||||
hidden: 0,
|
||||
truncated: false,
|
||||
});
|
||||
mount();
|
||||
await ready();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }));
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('17'));
|
||||
await waitFor(() => expect(screen.queryByText('Ben Beispiel')).toBeNull());
|
||||
expect(screen.getByText('Noch mit niemandem geteilt.')).toBeTruthy();
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shareRejected zeigt den deutschen Text und die Meldung der Nextcloud als zweite Zeile', async () => {
|
||||
mockCreate.mockRejectedValue(
|
||||
new NextcloudFilesRequestError(
|
||||
422,
|
||||
'shareRejected',
|
||||
'Nextcloud hat diese Freigabe abgelehnt.',
|
||||
{
|
||||
ncMessage: 'You cannot share a folder that contains other shares',
|
||||
},
|
||||
),
|
||||
);
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'be');
|
||||
const list = await screen.findByRole('listbox');
|
||||
fireEvent.click(within(list).getByRole('option', { name: /Ben Beispiel/ }));
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert.textContent).toContain('Nextcloud hat diese Freigabe abgelehnt.');
|
||||
expect(alert.textContent).toContain(
|
||||
'Meldung der Nextcloud: You cannot share a folder that contains other shares',
|
||||
);
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tooManyShares nennt die Wartezeit in Minuten', async () => {
|
||||
mockCreate.mockRejectedValue(
|
||||
new NextcloudFilesRequestError(429, 'tooManyShares', 'x', { retryAfterSeconds: 585 }),
|
||||
);
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'be');
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }),
|
||||
);
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Bitte warten Sie 10 Minuten.',
|
||||
);
|
||||
});
|
||||
|
||||
it('connectionExpired ruft onExpired', async () => {
|
||||
mockCreate.mockRejectedValue(
|
||||
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||
);
|
||||
mount();
|
||||
const input = await ready();
|
||||
type(input, 'be');
|
||||
fireEvent.click(
|
||||
within(await screen.findByRole('listbox')).getByRole('option', { name: /Ben/ }),
|
||||
);
|
||||
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('Teilen in der Nextcloud ausgeschaltet: nur der Hinweis, kein Suchfeld', async () => {
|
||||
mockPolicy.mockResolvedValue({ ...POLICY, enabled: false });
|
||||
mount();
|
||||
expect(await screen.findByText('Teilen ist in Ihrer Nextcloud ausgeschaltet.')).toBeTruthy();
|
||||
expect(screen.queryByRole('combobox')).toBeNull();
|
||||
});
|
||||
|
||||
it('Fertig schliesst den Dialog; der Titel nennt den Namen', async () => {
|
||||
mount();
|
||||
await ready();
|
||||
expect(screen.getByRole('dialog', { name: '„Projekte“ teilen' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fertig' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type ErrorLike,
|
||||
errorText,
|
||||
ncMessageOf,
|
||||
toErrorLike,
|
||||
} from '@/components/nextcloud-files/error-text';
|
||||
import {
|
||||
accessOptions,
|
||||
type ShareAccessChoice,
|
||||
type ShareTarget,
|
||||
} from '@/components/nextcloud-files/share-policy';
|
||||
import {
|
||||
createShare,
|
||||
deleteShare,
|
||||
getSharePolicy,
|
||||
listSharesForPath,
|
||||
type NcShare,
|
||||
type NcSharee,
|
||||
type NcSharePolicy,
|
||||
searchSharees,
|
||||
updateShare,
|
||||
} from '@/lib/nextcloud-files-api';
|
||||
import { Dialog } from './Dialog';
|
||||
import { CloseIcon, UserIcon, UsersIcon } from './icons';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const SELECT_CLASS =
|
||||
'rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60';
|
||||
|
||||
/** Datum `YYYY-MM-DD` in der Landessprache (Mittag, damit keine Zeitzone den Tag verschiebt). */
|
||||
function dateText(date: string, locale: string): string {
|
||||
const d = new Date(`${date}T12:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? date : d.toLocaleDateString(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilen-Dialog (quick-261009-dkv, D-19): Personen und Gruppen suchen und hinzufuegen,
|
||||
* Berechtigung aendern, Freigabe entfernen. Gebaut auf `Dialog`, damit die Tastenbelegung der
|
||||
* Dateiansicht (Entf, F2 ...) beim Tippen im Suchfeld nicht greift. Alle Regeln (was angeboten
|
||||
* wird, ob Gruppen gehen) kommen frisch von der Nextcloud ueber die API; Fehler erscheinen mit
|
||||
* dem eigenen Text und, falls vorhanden, der Meldung der Nextcloud als zweiter Zeile.
|
||||
*/
|
||||
export function ShareDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
onExpired,
|
||||
}: {
|
||||
target: ShareTarget;
|
||||
onClose: () => void;
|
||||
/** Nach jeder gelungenen Aenderung (die Dateiliste laedt neu). */
|
||||
onChanged: () => void;
|
||||
/** Die Verbindung ist abgelaufen. */
|
||||
onExpired: () => void;
|
||||
}) {
|
||||
const t = useTranslations('nextcloudFiles.share');
|
||||
const tCodes = useTranslations('nextcloudFiles.codes');
|
||||
const locale = useLocale();
|
||||
const ids = useId();
|
||||
const inputId = `${ids}-search`;
|
||||
const listId = `${ids}-list`;
|
||||
const optionId = (i: number) => `${ids}-opt-${i}`;
|
||||
|
||||
const [policy, setPolicy] = useState<NcSharePolicy | null>(null);
|
||||
const [shares, setShares] = useState<NcShare[]>([]);
|
||||
const [hidden, setHidden] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<ErrorLike | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [term, setTerm] = useState('');
|
||||
const [results, setResults] = useState<NcSharee[]>([]);
|
||||
const [searchedFor, setSearchedFor] = useState<string | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [newAccess, setNewAccess] = useState<ShareAccessChoice>('view');
|
||||
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const searchSeq = useRef(0);
|
||||
const onExpiredRef = useRef(onExpired);
|
||||
onExpiredRef.current = onExpired;
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const e = toErrorLike(err);
|
||||
if (e.code === 'connectionExpired') {
|
||||
onExpiredRef.current();
|
||||
return;
|
||||
}
|
||||
setError(e);
|
||||
};
|
||||
const failRef = useRef(fail);
|
||||
failRef.current = fail;
|
||||
|
||||
// Regeln und vorhandene Freigaben laden.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([getSharePolicy(), listSharesForPath(target.path)])
|
||||
.then(([p, list]) => {
|
||||
if (cancelled) return;
|
||||
setPolicy(p);
|
||||
setShares(list.shares);
|
||||
setHidden(list.hidden);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) failRef.current(err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [target.path]);
|
||||
|
||||
const enabled = policy?.enabled === true;
|
||||
const minChars = Math.max(1, policy?.minSearchLength ?? 1);
|
||||
|
||||
// Sobald der Dialog bereit ist, steht der Cursor im Suchfeld.
|
||||
useEffect(() => {
|
||||
if (!loading && enabled) input.current?.focus();
|
||||
}, [loading, enabled]);
|
||||
|
||||
// Suche mit Verzoegerung; veraltete Antworten werden verworfen.
|
||||
useEffect(() => {
|
||||
const seq = ++searchSeq.current;
|
||||
const q = term.trim();
|
||||
if (!policy?.enabled || q.length < minChars) {
|
||||
setResults([]);
|
||||
setSearching(false);
|
||||
setOpen(false);
|
||||
setSearchedFor(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
searchSharees(q, target.type)
|
||||
.then((out) => {
|
||||
if (seq !== searchSeq.current) return;
|
||||
setResults(out.sharees.filter((s) => s.kind === 'user' || policy.groupsEnabled));
|
||||
setSearchedFor(q);
|
||||
setActive(-1);
|
||||
setOpen(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (seq !== searchSeq.current) return;
|
||||
setResults([]);
|
||||
failRef.current(err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setSearching(false);
|
||||
});
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [term, policy, target.type, minChars]);
|
||||
|
||||
const sharedKeys = useMemo(
|
||||
() => new Set(shares.filter((s) => s.kind !== 'link').map((s) => `${s.kind}:${s.shareWith}`)),
|
||||
[shares],
|
||||
);
|
||||
const people = shares.filter((s) => s.kind !== 'link');
|
||||
const options = policy
|
||||
? accessOptions(target, 'user', policy)
|
||||
: (['view'] as ShareAccessChoice[]);
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const choose = (s: NcSharee) => {
|
||||
if (sharedKeys.has(`${s.kind}:${s.id}`)) return;
|
||||
void run(async () => {
|
||||
const created = await createShare({
|
||||
path: target.path,
|
||||
kind: s.kind,
|
||||
shareWith: s.id,
|
||||
access: newAccess,
|
||||
});
|
||||
setShares((prev) => [...prev, created]);
|
||||
setTerm('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
onChanged();
|
||||
input.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const changeAccess = (share: NcShare, access: ShareAccessChoice) =>
|
||||
run(async () => {
|
||||
const updated = await updateShare(share.id, { access });
|
||||
setShares((prev) => prev.map((s) => (s.id === share.id ? updated : s)));
|
||||
onChanged();
|
||||
});
|
||||
|
||||
const remove = (share: NcShare) =>
|
||||
run(async () => {
|
||||
await deleteShare(share.id);
|
||||
setShares((prev) => prev.filter((s) => s.id !== share.id));
|
||||
onChanged();
|
||||
});
|
||||
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (results.length === 0) return;
|
||||
setOpen(true);
|
||||
setActive((a) => Math.min(results.length - 1, a + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
if (open && active >= 0 && results[active]) {
|
||||
e.preventDefault();
|
||||
choose(results[active]);
|
||||
}
|
||||
} else if (e.key === 'Escape' && open) {
|
||||
// Erst die Ergebnisliste schliessen, nicht gleich den ganzen Dialog.
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nameOf = (s: NcShare) => s.shareWithName ?? s.shareWith ?? '';
|
||||
const trimmed = term.trim();
|
||||
const tooShort = trimmed !== '' && trimmed.length < minChars;
|
||||
const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0;
|
||||
const detail = error ? ncMessageOf(error) : null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
wide
|
||||
title={t('title', { name: target.name })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<button type="button" className="btn btn-primary" onClick={onClose}>
|
||||
{t('done')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p role="status" className="py-4 text-sm text-muted-foreground">
|
||||
{t('loading')}
|
||||
</p>
|
||||
) : !enabled ? (
|
||||
<p role="alert" className="py-2 text-sm text-foreground">
|
||||
{error ? errorText(tCodes, error, locale) : tCodes('sharingDisabled')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5" aria-busy={busy}>
|
||||
<section aria-labelledby={`${ids}-people`}>
|
||||
<h3 id={`${ids}-people`} className="text-sm font-semibold text-foreground">
|
||||
{t('people.title')}
|
||||
</h3>
|
||||
|
||||
<label htmlFor={inputId} className="mt-3 block text-sm text-muted-foreground">
|
||||
{t('people.searchLabel')}
|
||||
</label>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:flex-nowrap">
|
||||
<input
|
||||
ref={input}
|
||||
id={inputId}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={active >= 0 ? optionId(active) : undefined}
|
||||
value={term}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={busy}
|
||||
placeholder={t('people.searchPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setTerm(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<select
|
||||
aria-label={t('people.newAccess')}
|
||||
value={newAccess}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewAccess(e.target.value as ShareAccessChoice)}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{t(`access.${o}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mt-1.5 min-h-5 text-xs text-muted-foreground"
|
||||
>
|
||||
{searching
|
||||
? t('people.searching')
|
||||
: tooShort
|
||||
? t('people.minChars', { count: minChars })
|
||||
: noResults
|
||||
? t('people.noResults')
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
{open && results.length > 0 && (
|
||||
<ul
|
||||
id={listId}
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Ergebnisliste des Suchfelds (ARIA-Combobox-Muster)
|
||||
role="listbox"
|
||||
aria-label={t('people.results')}
|
||||
className="mt-1 max-h-56 overflow-y-auto rounded border border-border bg-card"
|
||||
>
|
||||
{results.map((s, i) => {
|
||||
const already = sharedKeys.has(`${s.kind}:${s.id}`);
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: Tastatur ueber das Suchfeld (Pfeile, Enter)
|
||||
<li
|
||||
key={`${s.kind}:${s.id}`}
|
||||
id={optionId(i)}
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Option der Combobox
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
aria-selected={active === i}
|
||||
aria-disabled={already || undefined}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(s)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm ${
|
||||
already
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer hover:bg-accent/70'
|
||||
} ${active === i ? 'bg-accent' : ''}`}
|
||||
>
|
||||
{s.kind === 'group' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{s.label}</span>
|
||||
{s.kind === 'group' && (
|
||||
<span className="text-xs text-muted-foreground">{t('people.group')}</span>
|
||||
)}
|
||||
{s.kind === 'user' && s.detail && s.detail !== s.label && (
|
||||
<span className="truncate text-xs text-muted-foreground">{s.detail}</span>
|
||||
)}
|
||||
{already && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('people.alreadyShared')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{people.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{t('people.empty')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border/70 rounded border border-border">
|
||||
{people.map((share) => {
|
||||
const rowOptions = options.includes(share.access as ShareAccessChoice)
|
||||
? options
|
||||
: [share.access, ...options];
|
||||
return (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-well text-muted-foreground"
|
||||
>
|
||||
{share.kind === 'group' ? <UsersIcon size={16} /> : <UserIcon size={16} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{nameOf(share)}
|
||||
{share.kind === 'group' && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{t('people.group')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{share.expiration && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('people.expires', { date: dateText(share.expiration, locale) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
aria-label={t('people.accessFor', { name: nameOf(share) })}
|
||||
value={share.access}
|
||||
disabled={busy || !share.canEdit}
|
||||
onChange={(e) =>
|
||||
void changeAccess(share, e.target.value as ShareAccessChoice)
|
||||
}
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{rowOptions.map((o) => (
|
||||
<option key={o} value={o} disabled={o === 'custom'}>
|
||||
{t(`access.${o}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('people.remove', { name: nameOf(share) })}
|
||||
title={t('people.remove', { name: nameOf(share) })}
|
||||
disabled={busy || !share.canDelete}
|
||||
onClick={() => void remove(share)}
|
||||
className="btn btn-subtle btn-icon h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CloseIcon size={16} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hidden > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('hidden', { count: hidden })}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && enabled && (
|
||||
<div role="alert" className="mt-4 text-sm text-status-down-fg">
|
||||
<p>{errorText(tCodes, error, locale)}</p>
|
||||
{detail && (
|
||||
<p className="mt-0.5 text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && !enabled && !loading && detail && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { NcEntry } from '@/lib/nextcloud-files-api';
|
||||
import { LinkIcon, UsersIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Freigabe-Kennzeichen am Namen eines Eintrags (quick-261009-dkv, D-19):
|
||||
* - ausgehend (`oc:share-types` nicht leer): Knopf mit Link-Symbol, wenn ein Link besteht,
|
||||
* sonst mit Personen-Symbol; er oeffnet den Teilen-Dialog.
|
||||
* - eingehend (Buchstabe S in den Berechtigungen): feststehendes Symbol mit Hinweistext,
|
||||
* ohne Knopf, weil fremde Freigaben hier nicht verwaltet werden.
|
||||
* Die Kennzeichen sind ruhige Symbole in gedaempfter Farbe, keine Etiketten.
|
||||
*/
|
||||
export function ShareIndicator({
|
||||
entry,
|
||||
onShare,
|
||||
}: {
|
||||
entry: Pick<NcEntry, 'name' | 'shareTypes' | 'permissions'>;
|
||||
/** Fehlt sie (Teilen ausgeschaltet), bleibt auch das ausgehende Kennzeichen ein Symbol. */
|
||||
onShare?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('nextcloudFiles.share.indicator');
|
||||
const outgoing = entry.shareTypes.length > 0;
|
||||
const incoming = !outgoing && entry.permissions.includes('S');
|
||||
if (!outgoing && !incoming) return null;
|
||||
|
||||
if (incoming) {
|
||||
return (
|
||||
<span
|
||||
data-testid="share-incoming"
|
||||
title={t('incoming')}
|
||||
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||
>
|
||||
<UsersIcon size={15} />
|
||||
<span className="sr-only">{t('incoming')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = entry.shareTypes.includes(3) ? LinkIcon : UsersIcon;
|
||||
const label = t('outgoing', { name: entry.name });
|
||||
if (!onShare) {
|
||||
return (
|
||||
<span
|
||||
data-testid="share-outgoing"
|
||||
title={label}
|
||||
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||
>
|
||||
<Icon size={15} />
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
data-testid="share-outgoing"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare();
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Icon size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -161,6 +161,43 @@ export const CloudIcon = (p: P) => (
|
||||
</Icon>
|
||||
);
|
||||
|
||||
// Teilen (quick-261009-dkv)
|
||||
export const ShareIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" x2="15.42" y1="13.51" y2="17.49" />
|
||||
<line x1="15.41" x2="8.59" y1="6.51" y2="10.49" />
|
||||
</Icon>
|
||||
);
|
||||
export const LinkIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</Icon>
|
||||
);
|
||||
export const UserIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</Icon>
|
||||
);
|
||||
export const UsersIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Icon>
|
||||
);
|
||||
export const CopyIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</Icon>
|
||||
);
|
||||
|
||||
export const FileGlyph = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
|
||||
@@ -117,6 +117,7 @@ beforeEach(() => {
|
||||
permissions: 'RGDNVCK',
|
||||
hasPreview: false,
|
||||
favorite: false,
|
||||
shareTypes: [],
|
||||
},
|
||||
],
|
||||
quota: { used: 0, available: null },
|
||||
|
||||
@@ -21,6 +21,18 @@ const KNOWN = new Set([
|
||||
'invalidPath',
|
||||
'nextcloudUnavailable',
|
||||
'selectionTooLarge',
|
||||
// Teilen (quick-261009-dkv)
|
||||
'sharingDisabled',
|
||||
'linkSharingDisabled',
|
||||
'shareAccessInvalid',
|
||||
'shareRecipientInvalid',
|
||||
'shareAlreadyExists',
|
||||
'sharePasswordRequired',
|
||||
'sharePasswordRejected',
|
||||
'shareExpiryRequired',
|
||||
'shareExpiryInvalid',
|
||||
'shareRejected',
|
||||
'shareNotFound',
|
||||
]);
|
||||
|
||||
export interface ErrorLike {
|
||||
@@ -29,6 +41,17 @@ export interface ErrorLike {
|
||||
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 {
|
||||
if (err instanceof NextcloudFilesRequestError) {
|
||||
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 {
|
||||
const code = error.code ?? '';
|
||||
if (code === 'nextcloudLocked') {
|
||||
if (code === 'nextcloudLocked' || code === 'tooManyShares') {
|
||||
const seconds = Number(error.extra?.retryAfterSeconds);
|
||||
const minutes = Math.max(
|
||||
1,
|
||||
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') {
|
||||
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,
|
||||
checkZip,
|
||||
createFolder,
|
||||
createShare,
|
||||
deleteEntry,
|
||||
deleteShare,
|
||||
downloadUrl,
|
||||
getSharePolicy,
|
||||
listFolder,
|
||||
listSharesForPath,
|
||||
moveEntry,
|
||||
NextcloudFilesRequestError,
|
||||
previewUrl,
|
||||
searchSharees,
|
||||
updateShare,
|
||||
ZIP_MAX_NAMES,
|
||||
zipFormFields,
|
||||
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;
|
||||
hasPreview: boolean;
|
||||
favorite: boolean;
|
||||
/** Freigabearten des Eintrags (0 Person, 1 Gruppe, 3 Link ...); leer = nicht geteilt. */
|
||||
shareTypes: number[];
|
||||
}
|
||||
|
||||
export interface NcQuota {
|
||||
@@ -315,3 +317,111 @@ export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: t
|
||||
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",
|
||||
"rename": "Umbenennen",
|
||||
"move": "Verschieben",
|
||||
"share": "Teilen",
|
||||
"openInNextcloud": "In Nextcloud öffnen",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
@@ -2572,6 +2573,38 @@
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -2590,7 +2623,21 @@
|
||||
"invalidPath": "Dieser Name ist nicht erlaubt.",
|
||||
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
|
||||
"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",
|
||||
"rename": "Rename",
|
||||
"move": "Move",
|
||||
"share": "Share",
|
||||
"openInNextcloud": "Open in Nextcloud",
|
||||
"delete": "Delete"
|
||||
},
|
||||
@@ -2572,6 +2573,38 @@
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -2590,7 +2623,21 @@
|
||||
"invalidPath": "This name is not allowed.",
|
||||
"nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.",
|
||||
"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“
|
||||
'ausstellen',
|
||||
'vergisst',
|
||||
// quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“
|
||||
'Suchergebnisse',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user