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:
2026-10-09 10:52:35 +02:00
parent 207d37b8d6
commit bf893afc61
19 changed files with 1640 additions and 12 deletions
@@ -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' });
});
});
+110
View File
@@ -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' });
}