From c3adf851b8a54c2deba12abd66454b9f6217503c Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 11:19:39 +0200 Subject: [PATCH] feat(quick-261009-dkv): Links im Teilen-Dialog, Von mir geteilt und Mit mir geteilt, Oberflaeche - Link-Formular: Berechtigung (Ansehen, Bearbeiten, Nur hochladen), Passwort verlangt oder freiwillig mit Passwort erzeugen, Ablaufdatum verlangt (vorbelegt, begrenzt) oder frei, Bezeichnung; Link kopieren, aendern, loeschen mit Rueckfrage - Ansichten Von mir geteilt und Mit mir geteilt mit offenen Freigaben (Annehmen, Ablehnen), Oeffnen in der Dateiansicht (initialPath/initialFocus), Freigabe verlassen - Reiter fuer jeden verbundenen Benutzer, ausgeblendet nur wenn die Nextcloud das Teilen ausschaltet - Hilfen (Passwortmodus, Ablaufregel, Datumsrechnung, Aenderungsunterschied), Web-Client, Meldungen de/en Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/FileBrowser.test.tsx | 38 +- .../components/FileBrowser.tsx | 12 +- .../components/LinkShareForm.tsx | 366 +++++++++++++++++ .../components/ShareDialog.test.tsx | 329 ++++++++++++++- .../components/ShareDialog.tsx | 303 +++++++++++++- .../components/SharesView.test.tsx | 342 ++++++++++++++++ .../nextcloud-files/components/SharesView.tsx | 380 ++++++++++++++++++ .../nextcloud-files-page.test.tsx | 147 +++++++ .../(portal)/modules/nextcloud-files/page.tsx | 78 +++- .../components/nextcloud-files/clipboard.ts | 21 + .../nextcloud-files/share-policy.test.ts | 129 +++++- .../nextcloud-files/share-policy.ts | 72 +++- apps/web/src/lib/nextcloud-files-api.test.ts | 42 ++ apps/web/src/lib/nextcloud-files-api.ts | 42 +- apps/web/src/messages/de.json | 93 ++++- apps/web/src/messages/en.json | 93 ++++- apps/web/src/messages/umlaut-dictionary.ts | 2 + 17 files changed, 2452 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx create mode 100644 apps/web/src/components/nextcloud-files/clipboard.ts diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx index 3f5339f..8e1cd16 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx @@ -138,14 +138,12 @@ beforeEach(() => { minSearchLength: 0, passwordMinLength: 10, }); - mockSharesForPath - .mockReset() - .mockImplementation(async (p: string) => ({ - path: p, - shares: [], - hidden: 0, - truncated: false, - })); + mockSharesForPath.mockReset().mockImplementation(async (p: string) => ({ + path: p, + shares: [], + hidden: 0, + truncated: false, + })); onExpired = vi.fn(); clicked = []; submitted = []; @@ -229,6 +227,30 @@ describe('FileBrowser — Liste', () => { expect(window.location.search).toBe(''); }); + it('initialPath und initialFocus haben Vorrang vor ?path= und fokussieren den genannten Eintrag nach dem Laden', async () => { + window.history.replaceState(null, '', '/modules/nextcloud-files?path=%2FZebra'); + render( + , + ); + const row = await screen.findByRole('row', { name: 'Plan.xlsx' }); + expect(mockList).toHaveBeenCalledWith('/Projekte'); + expect(mockList).not.toHaveBeenCalledWith('/Zebra'); + await waitFor(() => expect(document.activeElement).toBe(row)); + expect(window.location.search).toBe('?path=%2FProjekte'); + }); + + it('initialPath ohne initialFocus startet im Ordner, ohne einen Eintrag zu erzwingen', async () => { + render(); + await screen.findByRole('row', { name: 'Plan.xlsx' }); + // der erste Aufruf gilt dem Startordner (die Wurzel folgt nur fuer die Speicheranzeige) + expect(mockList.mock.calls[0][0]).toBe('/Projekte'); + }); + it('Enter oeffnet einen Ordner und laedt eine Datei herunter', async () => { await mount(); const file = screen.getByRole('row', { name: 'Bericht.pdf' }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx index 9992411..0b1eefe 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx @@ -148,6 +148,8 @@ export function FileBrowser({ serverUrl, onExpired, sharingEnabled = true, + initialPath, + initialFocus, }: { /** Adresse der Nextcloud fuer "In Nextcloud öffnen". */ serverUrl: string | null; @@ -155,6 +157,10 @@ export function FileBrowser({ onExpired: () => void; /** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */ sharingEnabled?: boolean; + /** Startordner (z. B. aus "Mit mir geteilt"); hat Vorrang vor `?path=`. */ + initialPath?: string; + /** Name des Eintrags in `initialPath`, der nach dem ersten Laden den Fokus bekommt. */ + initialFocus?: string; }) { const t = useTranslations('nextcloudFiles.browser'); const tCodes = useTranslations('nextcloudFiles.codes'); @@ -232,9 +238,13 @@ export function FileBrowser({ ); // Startordner aus der Adresse lesen (nur im Browser, daher erst nach dem Einhaengen). + // biome-ignore lint/correctness/useExhaustiveDependencies: der Start gilt nur beim Einhaengen useEffect(() => { - const raw = new URLSearchParams(window.location.search).get('path'); + const raw = initialPath ?? new URLSearchParams(window.location.search).get('path'); if (raw?.startsWith('/') && !raw.includes('\0')) setPath(raw); + if (initialPath && initialFocus) { + focusAfterLoad.current = `${initialPath.replace(/\/+$/, '')}/${initialFocus}`; + } setReady(true); }, []); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx new file mode 100644 index 0000000..4fa0b1e --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/LinkShareForm.tsx @@ -0,0 +1,366 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useId, useRef, useState } from 'react'; +import { copyText } from '@/components/nextcloud-files/clipboard'; +import { + accessOptions, + expiryRule, + generatePassword, + type LinkFormValues, + linkPasswordMode, + type ShareAccessChoice, + type ShareTarget, + todayLocal, +} from '@/components/nextcloud-files/share-policy'; +import type { NcShare, NcSharePolicy } from '@/lib/nextcloud-files-api'; + +const FIELD = + 'rounded border border-border bg-background px-3 py-2 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); +} + +type EditPassword = 'keep' | 'change' | 'remove'; + +/** + * Formular fuer einen oeffentlichen Link (quick-261009-dkv, D-07, D-08): Anlegen und Aendern. + * Was Pflicht ist, kommt aus den Regeln der Nextcloud des Benutzers (`policy`): verlangtes + * Passwort wird gefordert und kann erzeugt werden, verlangtes Ablaufdatum ist vorbelegt und auf + * das Hoechstdatum begrenzt, freiwillige Angaben bleiben frei. Die API prueft alles nochmals. + * Das Formular liefert nur Werte (`onSubmit`); gesendet wird in `ShareDialog`. + */ +export function LinkShareForm({ + target, + policy, + share, + busy, + onSubmit, + onCancel, +}: { + target: ShareTarget; + policy: NcSharePolicy; + /** Vorhandener Link beim Aendern. */ + share?: NcShare; + busy: boolean; + onSubmit: (values: LinkFormValues) => void; + onCancel: () => void; +}) { + const t = useTranslations('nextcloudFiles.share.linkForm'); + const tAccess = useTranslations('nextcloudFiles.share.access'); + const locale = useLocale(); + const ids = useId(); + const editing = share !== undefined; + + const options = accessOptions(target, 'link', policy); + const initialAccess: ShareAccessChoice = + share && options.includes(share.access as ShareAccessChoice) + ? (share.access as ShareAccessChoice) + : 'view'; + const [access, setAccess] = useState(initialAccess); + + // --- Passwort --- + const passwordMode = linkPasswordMode(policy); + const required = passwordMode === 'required'; + const [createPasswordOn, setCreatePasswordOn] = useState(passwordMode !== 'optional'); + // Beim Aendern eines Links ohne Passwort, obwohl die Nextcloud eins verlangt: gleich das Feld zeigen. + const hasPassword = share?.hasPassword === true; + const [editPassword, setEditPassword] = useState( + editing && required && !hasPassword ? 'change' : 'keep', + ); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [passwordCopied, setPasswordCopied] = useState(false); + const passwordInput = useRef(null); + const passwordFieldVisible = editing ? editPassword === 'change' : createPasswordOn; + + // --- Ablauf --- + const today = todayLocal(); + const rule = expiryRule(policy, today); + const [expiryOn, setExpiryOn] = useState( + editing + ? share.expiration !== null || rule.required + : rule.required || rule.defaultDate !== null, + ); + const [expireDate, setExpireDate] = useState( + editing ? (share.expiration ?? rule.defaultDate ?? '') : (rule.defaultDate ?? ''), + ); + + const [label, setLabel] = useState(share?.label ?? ''); + + const dateOutOfRange = + expiryOn && + expireDate !== '' && + (expireDate < rule.minDate || (rule.maxDate !== null && expireDate > rule.maxDate)); + const missingPassword = passwordFieldVisible && password.trim() === ''; + const missingDate = (rule.required || expiryOn) && expireDate === ''; + const canSubmit = !busy && !missingPassword && !missingDate && !dateOutOfRange; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + let outPassword: string | null; + if (editing) { + outPassword = editPassword === 'change' ? password : editPassword === 'remove' ? '' : null; + } else { + outPassword = createPasswordOn ? password : null; + } + onSubmit({ + access, + password: outPassword, + expireDate: expiryOn || rule.required ? expireDate : '', + label: label.trim(), + }); + }; + + const generate = () => { + setPassword(generatePassword(policy.passwordMinLength)); + setShowPassword(true); + setPasswordCopied(false); + }; + + const copyPassword = async () => { + const ok = await copyText(password, passwordInput.current); + setPasswordCopied(ok); + }; + + const passwordLabel = required ? t('passwordRequiredLabel') : t('passwordLabel'); + const expiryLabel = rule.required ? t('expiryRequiredLabel') : t('expiryLabel'); + + return ( +
+
+ {t('accessLegend')} + {options.map((o) => ( + + ))} +
+ +
+ {required ? ( +

{passwordLabel}

+ ) : editing && hasPassword ? ( +

{t('passwordLabel')}

+ ) : ( + + )} + + {editing && hasPassword && ( +
+ + {editPassword === 'remove' ? t('willRemove') : t('hasPassword')} + + {editPassword !== 'change' && ( + + )} + {!required && editPassword !== 'remove' && ( + + )} + {editPassword !== 'keep' && ( + + )} +
+ )} + + {passwordFieldVisible && ( +
+ +
+ { + setPassword(e.target.value); + setPasswordCopied(false); + }} + className={`${FIELD} min-w-0 flex-1 font-mono`} + /> + +
+
+ + + {policy.passwordMinLength !== null && ( + {t('minLength', { count: policy.passwordMinLength })} + )} +
+
+ )} +
+ +
+ {rule.required ? ( + + ) : ( + + )} + {(rule.required || expiryOn) && ( + <> + {!rule.required && ( + + )} + setExpireDate(e.target.value)} + className={FIELD} + /> + {rule.maxDate !== null && ( +

+ {t('expiryMax', { date: dateText(rule.maxDate, locale) })} +

+ )} + {dateOutOfRange && ( +

+ {t('expiryOutOfRange')} +

+ )} + + )} +
+ +
+ + setLabel(e.target.value)} + className={`${FIELD} w-full`} + /> +

{t('labelHint')}

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx index bc50512..c9b56e8 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.test.tsx @@ -1,7 +1,7 @@ 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 { addDays, type ShareTarget, todayLocal } from '@/components/nextcloud-files/share-policy'; import { type NcShare, type NcSharee, @@ -376,3 +376,330 @@ describe('ShareDialog — Personen und Gruppen', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +// --- Links (quick-261009-dkv, Aufgabe 2) ------------------------------------------------------------- + +const URL31 = 'https://cloud.example/nc/index.php/s/AbC123'; + +function link(over: Partial = {}): NcShare { + return share({ + id: '31', + kind: 'link', + shareWith: null, + shareWithName: null, + url: URL31, + label: '', + ...over, + }); +} + +function policyWith(over: Partial, extra: Partial = {}) { + return { ...POLICY, ...extra, links: { ...POLICY.links, ...over } }; +} + +async function openCreate() { + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link erstellen' })); + return screen.getByRole('form', { name: 'Link erstellen' }); +} + +describe('ShareDialog — Links', () => { + beforeEach(() => { + mockCreate.mockImplementation(async (input: { kind: string; label?: string }) => + input.kind === 'link' ? link({ id: '31', label: input.label ?? '' }) : share(), + ); + }); + + it('verlangtes Passwort: Pflichtfeld, Erzeugen fuellt es (mindestens 20 Zeichen, sichtbar), gesendet wird genau dieser Wert', async () => { + mockPolicy.mockResolvedValue(policyWith({ passwordRequired: true })); + const form = await openCreate(); + const field = within(form).getByLabelText( + 'Passwort (Ihre Nextcloud verlangt eines)', + ) as HTMLInputElement; + const submit = within(form).getByRole('button', { + name: 'Link erstellen', + }) as HTMLButtonElement; + expect(field.required).toBe(true); + expect(submit.disabled).toBe(true); + // es gibt keinen Schalter, der das Passwort abwaehlt + expect(within(form).queryByRole('checkbox', { name: 'Mit Passwort schützen' })).toBeNull(); + + fireEvent.click(within(form).getByRole('button', { name: 'Passwort erzeugen' })); + expect(field.type).toBe('text'); + expect(field.value.length).toBeGreaterThanOrEqual(20); + const generated = field.value; + expect(submit.disabled).toBe(false); + + fireEvent.click(submit); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate).toHaveBeenCalledWith({ + path: '/Projekte', + kind: 'link', + access: 'view', + password: generated, + expireDate: '', + }); + // der neue Link zeigt Adresse und den Hinweis, das Passwort getrennt weiterzugeben + expect(((await screen.findByLabelText('Adresse des Links')) as HTMLInputElement).value).toBe( + URL31, + ); + expect(screen.getByText(/Geben Sie das Passwort getrennt vom Link weiter/)).toBeTruthy(); + expect(screen.getByText(generated)).toBeTruthy(); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it('freiwilliges Passwort: Schalter aus, kein Feld, kein Passwort im Aufruf; Ablauf leer wird als leere Zeichenkette gesendet', async () => { + const form = await openCreate(); + const toggle = within(form).getByRole('checkbox', { + name: 'Mit Passwort schützen', + }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + expect(within(form).queryByRole('button', { name: 'Passwort erzeugen' })).toBeNull(); + fireEvent.click(within(form).getByRole('button', { name: 'Link erstellen' })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate).toHaveBeenCalledWith({ + path: '/Projekte', + kind: 'link', + access: 'view', + expireDate: '', + }); + }); + + it('Nextcloud schlaegt ein Passwort vor: Schalter ist an', async () => { + mockPolicy.mockResolvedValue(policyWith({ passwordSuggested: true })); + const form = await openCreate(); + expect( + (within(form).getByRole('checkbox', { name: 'Mit Passwort schützen' }) as HTMLInputElement) + .checked, + ).toBe(true); + }); + + it('verlangter Ablauf: Datum ist mit heute plus Tage vorbelegt, nach oben begrenzt und nicht abwaehlbar', async () => { + mockPolicy.mockResolvedValue(policyWith({ expiryEnforced: true, expiryDefaultDays: 7 })); + const form = await openCreate(); + const date = within(form).getByLabelText( + 'Ablaufdatum (Ihre Nextcloud verlangt eines)', + ) as HTMLInputElement; + const want = addDays(todayLocal(), 7); + expect(date.value).toBe(want); + expect(date.max).toBe(want); + expect(date.min).toBe(todayLocal()); + expect(date.required).toBe(true); + expect(within(form).queryByRole('checkbox', { name: 'Ohne Ablaufdatum' })).toBeNull(); + fireEvent.click(within(form).getByRole('button', { name: 'Link erstellen' })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0].expireDate).toBe(want); + }); + + it('ein Datum nach dem Hoechstdatum sperrt das Absenden', async () => { + mockPolicy.mockResolvedValue(policyWith({ expiryEnforced: true, expiryDefaultDays: 7 })); + const form = await openCreate(); + const date = within(form).getByLabelText('Ablaufdatum (Ihre Nextcloud verlangt eines)'); + fireEvent.change(date, { target: { value: addDays(todayLocal(), 30) } }); + expect( + (within(form).getByRole('button', { name: 'Link erstellen' }) as HTMLButtonElement).disabled, + ).toBe(true); + expect(within(form).getByText('Dieses Datum lässt Ihre Nextcloud nicht zu.')).toBeTruthy(); + }); + + it('freiwilliger Ablauf mit Vorgabe: vorbelegt; "Ohne Ablaufdatum" sendet eine leere Zeichenkette', async () => { + mockPolicy.mockResolvedValue(policyWith({ expiryDefaultDays: 7 })); + const form = await openCreate(); + const date = within(form).getByLabelText('Ablaufdatum') as HTMLInputElement; + expect(date.value).toBe(addDays(todayLocal(), 7)); + expect(date.max).toBe(''); + fireEvent.click(within(form).getByRole('checkbox', { name: 'Ohne Ablaufdatum' })); + expect(within(form).queryByLabelText('Ablaufdatum')).toBeNull(); + fireEvent.click(within(form).getByRole('button', { name: 'Link erstellen' })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0].expireDate).toBe(''); + }); + + it('freiwilliger Ablauf ohne Vorgabe: Schalter "Ablaufdatum festlegen" blendet das Feld ein', async () => { + const form = await openCreate(); + expect(within(form).queryByLabelText('Ablaufdatum')).toBeNull(); + fireEvent.click(within(form).getByRole('checkbox', { name: 'Ablaufdatum festlegen' })); + const date = within(form).getByLabelText('Ablaufdatum') as HTMLInputElement; + fireEvent.change(date, { target: { value: addDays(todayLocal(), 3) } }); + fireEvent.click(within(form).getByRole('button', { name: 'Link erstellen' })); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0].expireDate).toBe(addDays(todayLocal(), 3)); + }); + + it('"Nur hochladen" gibt es nur bei Ordnern und nur, wenn die Nextcloud es erlaubt', async () => { + const form = await openCreate(); + expect(within(form).getByRole('radio', { name: /Nur hochladen/ })).toBeTruthy(); + expect( + within(form).getByText(/Andere legen Dateien in diesen Ordner, sehen aber nichts darin/), + ).toBeTruthy(); + cleanup(); + + mockList.mockResolvedValue({ path: '/B.txt', shares: [], hidden: 0, truncated: false }); + mount({ path: '/B.txt', name: 'B.txt', type: 'file', mime: 'text/plain', writable: true }); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link erstellen' })); + const fileForm = screen.getByRole('form', { name: 'Link erstellen' }); + expect(within(fileForm).queryByRole('radio', { name: /Nur hochladen/ })).toBeNull(); + expect(within(fileForm).getByRole('radio', { name: /Bearbeiten/ })).toBeTruthy(); + cleanup(); + + mockPolicy.mockResolvedValue(policyWith({ uploadAllowed: false })); + mockList.mockResolvedValue({ path: '/Projekte', shares: [], hidden: 0, truncated: false }); + const noUpload = await openCreate(); + expect(within(noUpload).queryByRole('radio', { name: /Nur hochladen/ })).toBeNull(); + expect(within(noUpload).queryByRole('radio', { name: /Bearbeiten/ })).toBeNull(); + }); + + it('der Link zeigt seine Adresse schreibgeschuetzt; "Link kopieren" schreibt sie in die Zwischenablage und meldet "Kopiert"', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [link({ label: 'Kunde', hasPassword: true, expiration: '2026-12-01' })], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + const urlField = screen.getByLabelText('Adresse des Links') as HTMLInputElement; + expect(urlField.readOnly).toBe(true); + expect(urlField.value).toBe(URL31); + expect(screen.getByText(/Kunde/)).toBeTruthy(); + expect(screen.getByText(/mit Passwort/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Link kopieren' })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(URL31)); + expect(await screen.findByRole('button', { name: 'Kopiert' })).toBeTruthy(); + }); + + it('Löschen fragt erst nach; nur die Bestaetigung ruft deleteShare', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [link()], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link „Link“ löschen' })); + expect( + screen.getByText('Link löschen? Wer ihn hat, kommt danach nicht mehr an „Projekte“.'), + ).toBeTruthy(); + expect(mockDelete).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' })); + expect(mockDelete).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Link „Link“ löschen' })); + fireEvent.click(screen.getByRole('button', { name: 'Ja, löschen' })); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('31')); + await waitFor(() => expect(screen.queryByLabelText('Adresse des Links')).toBeNull()); + }); + + it('Ändern sendet nur die geaenderten Felder', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [link({ label: 'Kunde', expiration: '2026-12-01', hasPassword: true })], + hidden: 0, + truncated: false, + }); + mockUpdate.mockResolvedValue( + link({ label: 'Angebot', expiration: '2026-12-01', hasPassword: true }), + ); + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link „Kunde“ ändern' })); + const form = screen.getByRole('form', { name: 'Link ändern' }); + fireEvent.change(within(form).getByLabelText('Bezeichnung (freiwillig)'), { + target: { value: 'Angebot' }, + }); + fireEvent.click(within(form).getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(mockUpdate).toHaveBeenCalledWith('31', { label: 'Angebot' }); + }); + + it('Passwort entfernen ist nur ohne Pflicht moeglich und sendet eine leere Zeichenkette', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [link({ hasPassword: true })], + hidden: 0, + truncated: false, + }); + mockUpdate.mockResolvedValue(link()); + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link „Link“ ändern' })); + const form = screen.getByRole('form', { name: 'Link ändern' }); + fireEvent.click(within(form).getByRole('button', { name: 'Passwort entfernen' })); + fireEvent.click(within(form).getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith('31', { password: '' })); + cleanup(); + + mockPolicy.mockResolvedValue(policyWith({ passwordRequired: true })); + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Link „Link“ ändern' })); + expect(screen.queryByRole('button', { name: 'Passwort entfernen' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Passwort ändern' })).toBeTruthy(); + }); + + it('sharePasswordRejected zeigt den Text und die Meldung der Nextcloud als zweite Zeile', async () => { + mockPolicy.mockResolvedValue(policyWith({ passwordRequired: true })); + mockCreate.mockRejectedValue( + new NextcloudFilesRequestError(400, 'sharePasswordRejected', 'abgelehnt', { + ncMessage: 'Password is too short', + }), + ); + const form = await openCreate(); + fireEvent.change(within(form).getByLabelText('Passwort (Ihre Nextcloud verlangt eines)'), { + target: { value: 'abc' }, + }); + fireEvent.click(within(form).getByRole('button', { name: 'Link erstellen' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Nextcloud lehnt dieses Passwort ab'); + expect(alert.textContent).toContain('Meldung der Nextcloud: Password is too short'); + // das Formular bleibt offen, nichts wurde als geaendert gemeldet + expect(screen.getByRole('form', { name: 'Link erstellen' })).toBeTruthy(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('Links ausgeschaltet: Hinweis statt Abschnitt', async () => { + mockPolicy.mockResolvedValue(policyWith({ enabled: false })); + mount(); + await ready(); + expect( + screen.getByText('Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.'), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Link erstellen' })).toBeNull(); + }); + + it('nur ein Link erlaubt und schon einer da: kein weiterer Knopf; sonst "Weiteren Link erstellen"', async () => { + mockPolicy.mockResolvedValue(policyWith({ multipleLinks: false })); + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [link()], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + expect(screen.queryByRole('button', { name: 'Weiteren Link erstellen' })).toBeNull(); + cleanup(); + + mockPolicy.mockResolvedValue(POLICY); + mount(); + await ready(); + expect(screen.getByRole('button', { name: 'Weiteren Link erstellen' })).toBeTruthy(); + }); + + it('Links erscheinen nicht in der Liste der Personen', async () => { + mockList.mockResolvedValue({ + path: '/Projekte', + shares: [share(), link()], + hidden: 0, + truncated: false, + }); + mount(); + await ready(); + expect(screen.getAllByLabelText(/^Berechtigung für (?!die nächste)/)).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx index 41cf05f..4396109 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/ShareDialog.tsx @@ -8,8 +8,11 @@ import { ncMessageOf, toErrorLike, } from '@/components/nextcloud-files/error-text'; +import { copyText } from '@/components/nextcloud-files/clipboard'; import { accessOptions, + type LinkFormValues, + linkUpdateDiff, type ShareAccessChoice, type ShareTarget, } from '@/components/nextcloud-files/share-policy'; @@ -25,7 +28,8 @@ import { updateShare, } from '@/lib/nextcloud-files-api'; import { Dialog } from './Dialog'; -import { CloseIcon, UserIcon, UsersIcon } from './icons'; +import { CloseIcon, CopyIcon, LinkIcon, UserIcon, UsersIcon } from './icons'; +import { LinkShareForm } from './LinkShareForm'; const SEARCH_DEBOUNCE_MS = 300; const SELECT_CLASS = @@ -37,9 +41,13 @@ function dateText(date: string, locale: string): string { return Number.isNaN(d.getTime()) ? date : d.toLocaleDateString(locale); } +type Section = 'people' | 'link'; +type LinkPanel = { mode: 'create' } | { mode: 'edit'; id: string }; + /** * Teilen-Dialog (quick-261009-dkv, D-19): Personen und Gruppen suchen und hinzufuegen, - * Berechtigung aendern, Freigabe entfernen. Gebaut auf `Dialog`, damit die Tastenbelegung der + * Berechtigung aendern, Freigabe entfernen; oeffentliche Links anlegen, kopieren, aendern und + * loeschen (Passwort und Ablauf nach den Regeln der Nextcloud, siehe `LinkShareForm`). 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. @@ -70,8 +78,17 @@ export function ShareDialog({ const [hidden, setHidden] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [errorSection, setErrorSection] = useState
('people'); const [busy, setBusy] = useState(false); + const [linkPanel, setLinkPanel] = useState(null); + const [confirmId, setConfirmId] = useState(null); + /** Das Passwort eines gerade angelegten Links: nur solange der Dialog offen ist, nie gespeichert. */ + const [revealed, setRevealed] = useState<{ id: string; password: string } | null>(null); + const [revealedCopied, setRevealedCopied] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const urlInputs = useRef(new Map()); + const [term, setTerm] = useState(''); const [results, setResults] = useState([]); const [searchedFor, setSearchedFor] = useState(null); @@ -164,14 +181,16 @@ export function ShareDialog({ [shares], ); const people = shares.filter((s) => s.kind !== 'link'); + const links = shares.filter((s) => s.kind === 'link'); const options = policy ? accessOptions(target, 'user', policy) : (['view'] as ShareAccessChoice[]); - const run = async (action: () => Promise) => { + const run = async (section: Section, action: () => Promise) => { if (busy) return; setBusy(true); setError(null); + setErrorSection(section); try { await action(); } catch (err) { @@ -183,7 +202,7 @@ export function ShareDialog({ const choose = (s: NcSharee) => { if (sharedKeys.has(`${s.kind}:${s.id}`)) return; - void run(async () => { + void run('people', async () => { const created = await createShare({ path: target.path, kind: s.kind, @@ -200,19 +219,75 @@ export function ShareDialog({ }; const changeAccess = (share: NcShare, access: ShareAccessChoice) => - run(async () => { + run('people', 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 () => { + run('people', async () => { await deleteShare(share.id); setShares((prev) => prev.filter((s) => s.id !== share.id)); onChanged(); }); + // --- Links --- + + const submitLink = (values: LinkFormValues) => { + const panel = linkPanel; + if (!panel) return; + void run('link', async () => { + if (panel.mode === 'create') { + const created = await createShare({ + path: target.path, + kind: 'link', + access: values.access, + ...(values.password ? { password: values.password } : {}), + // Immer senden (Datum oder leer), damit die Nextcloud keinen eigenen Ablauf einsetzt. + expireDate: values.expireDate, + ...(values.label ? { label: values.label } : {}), + }); + setShares((prev) => [...prev, created]); + setRevealed(values.password ? { id: created.id, password: values.password } : null); + setRevealedCopied(false); + } else { + const current = shares.find((s) => s.id === panel.id); + if (current) { + const diff = linkUpdateDiff(current, values); + if (Object.keys(diff).length > 0) { + const updated = await updateShare(current.id, diff); + setShares((prev) => prev.map((s) => (s.id === current.id ? updated : s))); + if (values.password) setRevealed({ id: current.id, password: values.password }); + else if (values.password === '') setRevealed(null); + } + } + } + setLinkPanel(null); + onChanged(); + }); + }; + + const removeLink = (share: NcShare) => + run('link', async () => { + await deleteShare(share.id); + setShares((prev) => prev.filter((s) => s.id !== share.id)); + setConfirmId(null); + setRevealed((r) => (r?.id === share.id ? null : r)); + onChanged(); + }); + + const copyLink = async (share: NcShare) => { + if (!share.url) return; + const ok = await copyText(share.url, urlInputs.current.get(share.id)); + setCopiedId(ok ? share.id : null); + }; + + const copyRevealed = async () => { + if (!revealed) return; + setRevealedCopied(await copyText(revealed.password)); + }; + const onInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); @@ -240,6 +315,14 @@ export function ShareDialog({ const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0; const detail = error ? ncMessageOf(error) : null; + const errorBlock = (section: Section) => + error && errorSection === section ? ( +
+

{errorText(tCodes, error, locale)}

+ {detail &&

{tCodes('ncDetail', { detail })}

} +
+ ) : null; + return ( 0 && (

{t('hidden', { count: hidden })}

)} + {errorBlock('people')} +
+ +
+

+ {t('link.title')} +

+ {!policy?.links.enabled ? ( +

{tCodes('linkSharingDisabled')}

+ ) : ( + <> + {links.length === 0 && linkPanel === null && ( +

{t('link.empty')}

+ )} + {links.length > 0 && ( +
    + {links.map((share) => { + const editingThis = linkPanel?.mode === 'edit' && linkPanel.id === share.id; + const confirming = confirmId === share.id; + const name = share.label || t('link.defaultName'); + return ( +
  • +
    + +
    +

    {name}

    +

    + {[ + t(`access.${share.access}`), + share.hasPassword ? t('link.withPassword') : null, + share.expiration + ? t('people.expires', { + date: dateText(share.expiration, locale), + }) + : null, + ] + .filter(Boolean) + .join(', ')} +

    +
    +
    + + +
    +
    + + {share.url && ( +
    + { + if (el) urlInputs.current.set(share.id, el); + else urlInputs.current.delete(share.id); + }} + type="text" + readOnly + value={share.url} + aria-label={t('link.urlLabel')} + onFocus={(e) => e.currentTarget.select()} + className="min-w-0 flex-1 rounded border border-border bg-background px-3 py-1.5 font-mono text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
    + )} +

    + {copiedId === share.id ? t('link.copied') : ''} +

    + + {revealed?.id === share.id && ( +
    +

    {t('link.passwordNote')}

    +
    + {t('link.passwordOfLink')} + + {revealed.password} + + + +
    +
    + )} + + {confirming && ( +
    + {t('link.deleteAria', { name })} +

    + {t('link.deleteConfirm', { name: target.name })} +

    +
    + + +
    +
    + )} + + {editingThis && policy && ( + setLinkPanel(null)} + /> + )} +
  • + ); + })} +
+ )} + + {linkPanel?.mode === 'create' && policy ? ( + setLinkPanel(null)} + /> + ) : ( + (links.length === 0 || policy?.links.multipleLinks) && ( + + ) + )} + + )} + {errorBlock('link')}
)} - {error && enabled && ( -
-

{errorText(tCodes, error, locale)}

- {detail && ( -

{tCodes('ncDetail', { detail })}

- )} -
- )} {error && !enabled && !loading && detail && (

{tCodes('ncDetail', { detail })}

)} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx new file mode 100644 index 0000000..8d3d8bc --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.test.tsx @@ -0,0 +1,342 @@ +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 NcShare, NextcloudFilesRequestError } from '@/lib/nextcloud-files-api'; +import de from '@/messages/de.json'; +import { SharesView } from './SharesView'; + +const mockMine = vi.fn(); +const mockReceived = vi.fn(); +const mockAccept = vi.fn(); +const mockDelete = vi.fn(); +const mockPolicy = vi.fn(); +const mockByPath = vi.fn(); + +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listMyShares: (...a: unknown[]) => mockMine(...a), + listReceivedShares: (...a: unknown[]) => mockReceived(...a), + acceptShare: (...a: unknown[]) => mockAccept(...a), + deleteShare: (...a: unknown[]) => mockDelete(...a), + getSharePolicy: (...a: unknown[]) => mockPolicy(...a), + listSharesForPath: (...a: unknown[]) => mockByPath(...a), + searchSharees: vi.fn().mockResolvedValue({ sharees: [] }), + }; +}); + +function share(over: Partial = {}): NcShare { + return { + id: '17', + kind: 'user', + path: '/Projekte', + name: 'Projekte', + itemType: 'folder', + mime: null, + itemWritable: true, + permissions: 15, + access: 'edit', + 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 incoming = (over: Partial = {}) => + share({ + id: '40', + path: '/Ben-Ordner', + name: 'Ben-Ordner', + target: '/Ben-Ordner', + ownerId: 'ben', + ownerName: 'Ben Beispiel', + shareWith: 'anna', + shareWithName: 'Anna Müller', + permissions: 1, + access: 'view', + itemWritable: false, + canEdit: false, + ...over, + }); + +let onOpen: ReturnType; +let onExpired: ReturnType; + +function mount(mode: 'byMe' | 'withMe') { + return render( + + + , + ); +} + +beforeEach(() => { + onOpen = vi.fn(); + onExpired = vi.fn(); + mockMine.mockReset().mockResolvedValue({ shares: [], hidden: 0, truncated: false }); + mockReceived + .mockReset() + .mockResolvedValue({ shares: [], pending: [], hidden: 0, truncated: false }); + mockAccept.mockReset().mockResolvedValue({ accepted: true }); + mockDelete.mockReset().mockResolvedValue({ deleted: 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, + }); + mockByPath.mockReset().mockResolvedValue({ path: '/', shares: [], hidden: 0, truncated: false }); +}); + +afterEach(() => cleanup()); + +describe('SharesView — Von mir geteilt', () => { + it('leer: erklaert, wie man teilt', async () => { + mount('byMe'); + expect(await screen.findByText(/Sie haben noch nichts geteilt/)).toBeTruthy(); + expect(screen.getByText(/wählen Sie „Teilen“/)).toBeTruthy(); + }); + + it('fasst die Freigaben je Eintrag zusammen: Empfaenger, Link, Berechtigung, Ablauf', async () => { + mockMine.mockResolvedValue({ + shares: [ + share(), + share({ + id: '18', + kind: 'group', + shareWith: 'team', + shareWithName: 'team', + access: 'view', + }), + share({ + id: '19', + kind: 'link', + shareWith: null, + shareWithName: null, + access: 'upload', + hasPassword: true, + expiration: '2026-12-01', + url: 'https://cloud.example/s/x', + }), + share({ + id: '20', + path: '/Bericht.txt', + name: 'Bericht.txt', + itemType: 'file', + mime: 'text/plain', + }), + ], + hidden: 2, + truncated: false, + }); + mount('byMe'); + const rows = await screen.findAllByRole('listitem'); + expect(rows).toHaveLength(2); + const projekte = rows[0]; + expect(within(projekte).getByText('Projekte')).toBeTruthy(); + expect(within(projekte).getByText('in Hauptordner')).toBeTruthy(); + const text = projekte.textContent ?? ''; + expect(text).toContain('Ben Beispiel (Bearbeiten)'); + expect(text).toContain('Gruppe team (Ansehen)'); + expect(text).toContain('Link (Nur hochladen, mit Passwort, gültig bis'); + expect(screen.getByText(/2 weitere Freigaben/)).toBeTruthy(); + }); + + it('"Im Ordner zeigen" oeffnet den Ordner darueber und nennt den Eintrag', async () => { + mockMine.mockResolvedValue({ + shares: [share({ path: '/Kunden/Projekte' })], + hidden: 0, + truncated: false, + }); + mount('byMe'); + fireEvent.click(await screen.findByRole('button', { name: '„Projekte“ im Ordner zeigen' })); + expect(onOpen).toHaveBeenCalledWith('/Kunden', 'Projekte'); + }); + + it('"Freigaben bearbeiten" oeffnet den Teilen-Dialog des Eintrags; Aenderungen laden die Liste neu', async () => { + mockMine.mockResolvedValue({ shares: [share()], hidden: 0, truncated: false }); + mockByPath.mockResolvedValue({ + path: '/Projekte', + shares: [share()], + hidden: 0, + truncated: false, + }); + mount('byMe'); + fireEvent.click( + await screen.findByRole('button', { name: 'Freigaben von „Projekte“ bearbeiten' }), + ); + const dialog = await screen.findByRole('dialog', { name: '„Projekte“ teilen' }); + expect(mockByPath).toHaveBeenCalledWith('/Projekte'); + await within(dialog).findByRole('combobox', { name: 'Person oder Gruppe suchen' }); + mockMine.mockClear(); + fireEvent.click( + await within(dialog).findByRole('button', { name: 'Freigabe für Ben Beispiel entfernen' }), + ); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('17')); + await waitFor(() => expect(mockMine).toHaveBeenCalled()); + }); + + it('connectionExpired ruft onExpired', async () => { + mockMine.mockRejectedValue(new NextcloudFilesRequestError(409, 'connectionExpired', 'x')); + mount('byMe'); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + }); + + it('ein anderer Fehler steht als Meldung mit Nextcloud-Zeile da', async () => { + mockMine.mockRejectedValue( + new NextcloudFilesRequestError(502, 'nextcloudError', 'x', { ncMessage: 'kaputt' }), + ); + mount('byMe'); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Meldung der Nextcloud: kaputt'); + }); +}); + +describe('SharesView — Mit mir geteilt', () => { + it('leer: kurzer Hinweis', async () => { + mount('withMe'); + expect(await screen.findByText('Mit Ihnen hat noch niemand etwas geteilt.')).toBeTruthy(); + }); + + it('listet Eigentuemer, Berechtigung und Ablauf', async () => { + mockReceived.mockResolvedValue({ + shares: [incoming({ expiration: '2026-12-01' })], + pending: [], + hidden: 0, + truncated: false, + }); + mount('withMe'); + expect(await screen.findByText('Ben-Ordner')).toBeTruthy(); + const text = screen.getByRole('listitem').textContent ?? ''; + expect(text).toContain('von Ben Beispiel'); + expect(text).toContain('Ansehen'); + expect(text).toContain('gültig bis'); + expect(screen.queryByText('Noch nicht angenommen')).toBeNull(); + }); + + it('"Öffnen": Ordner geht auf den Zielpfad, Datei auf den Ordner darueber mit dem Namen', async () => { + mockReceived.mockResolvedValue({ + shares: [ + incoming(), + incoming({ + id: '41', + name: 'Plan.pdf', + itemType: 'file', + mime: 'application/pdf', + target: '/Ben/Plan.pdf', + path: '/Ben/Plan.pdf', + }), + ], + pending: [], + hidden: 0, + truncated: false, + }); + mount('withMe'); + fireEvent.click(await screen.findByRole('button', { name: '„Ben-Ordner“ öffnen' })); + expect(onOpen).toHaveBeenLastCalledWith('/Ben-Ordner'); + fireEvent.click(screen.getByRole('button', { name: '„Plan.pdf“ öffnen' })); + expect(onOpen).toHaveBeenLastCalledWith('/Ben', 'Plan.pdf'); + }); + + it('"Freigabe verlassen" fragt nach; erst die Bestaetigung ruft deleteShare und laedt neu', async () => { + mockReceived.mockResolvedValue({ + shares: [incoming()], + pending: [], + hidden: 0, + truncated: false, + }); + mount('withMe'); + fireEvent.click(await screen.findByRole('button', { name: 'Freigabe „Ben-Ordner“ verlassen' })); + expect(mockDelete).not.toHaveBeenCalled(); + expect(screen.getByText(/nicht mehr mit Ihnen teilen lassen/)).toBeTruthy(); + mockReceived.mockResolvedValue({ shares: [], pending: [], hidden: 0, truncated: false }); + fireEvent.click(screen.getByRole('button', { name: 'Ja, verlassen' })); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('40')); + expect(await screen.findByText('Mit Ihnen hat noch niemand etwas geteilt.')).toBeTruthy(); + expect(mockReceived).toHaveBeenCalledTimes(2); + }); + + it('noch nicht angenommene Freigaben stehen oben: Annehmen und Ablehnen', async () => { + const pending = incoming({ + id: '42', + name: 'Ben-Briefkasten', + target: '/Ben-Briefkasten', + pending: true, + }); + mockReceived.mockResolvedValue({ + shares: [incoming()], + pending: [pending], + hidden: 0, + truncated: false, + }); + mount('withMe'); + expect(await screen.findByText('Noch nicht angenommen')).toBeTruthy(); + // keine "Öffnen"-Aktion fuer eine offene Freigabe + expect(screen.queryByRole('button', { name: '„Ben-Briefkasten“ öffnen' })).toBeNull(); + + mockReceived.mockResolvedValue({ + shares: [incoming(), incoming({ id: '42', name: 'Ben-Briefkasten' })], + pending: [], + hidden: 0, + truncated: false, + }); + fireEvent.click(screen.getByRole('button', { name: '„Ben-Briefkasten“ annehmen' })); + await waitFor(() => expect(mockAccept).toHaveBeenCalledWith('42')); + expect(mockDelete).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText('Noch nicht angenommen')).toBeNull()); + expect(await screen.findByRole('button', { name: '„Ben-Briefkasten“ öffnen' })).toBeTruthy(); + }); + + it('Ablehnen ruft deleteShare, nicht acceptShare', async () => { + mockReceived.mockResolvedValue({ + shares: [], + pending: [incoming({ id: '42', name: 'Ben-Briefkasten', pending: true })], + hidden: 0, + truncated: false, + }); + mount('withMe'); + mockReceived.mockResolvedValue({ shares: [], pending: [], hidden: 0, truncated: false }); + fireEvent.click(await screen.findByRole('button', { name: '„Ben-Briefkasten“ ablehnen' })); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('42')); + expect(mockAccept).not.toHaveBeenCalled(); + }); + + it('weitere Arten erscheinen nur als Zahl; eine abgeschnittene Liste wird erklaert', async () => { + mockReceived.mockResolvedValue({ + shares: [incoming()], + pending: [], + hidden: 1, + truncated: true, + }); + mount('withMe'); + expect(await screen.findByText(/Eine weitere Freigabe/)).toBeTruthy(); + expect(screen.getByText(/Es werden nicht alle Freigaben angezeigt/)).toBeTruthy(); + }); + + it('connectionExpired ruft onExpired', async () => { + mockReceived.mockRejectedValue(new NextcloudFilesRequestError(409, 'connectionExpired', 'x')); + mount('withMe'); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx new file mode 100644 index 0000000..7f60ca2 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SharesView.tsx @@ -0,0 +1,380 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type ErrorLike, + errorText, + ncMessageOf, + toErrorLike, +} from '@/components/nextcloud-files/error-text'; +import type { ShareTarget } from '@/components/nextcloud-files/share-policy'; +import { + acceptShare, + deleteShare, + listMyShares, + listReceivedShares, + type NcShare, +} from '@/lib/nextcloud-files-api'; +import { ShareDialog } from './ShareDialog'; +import { TypeTile } from './TypeTile'; + +/** 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); +} + +/** Ordner, in dem ein Pfad liegt (`/` fuer die Wurzel). */ +function parentOf(path: string): string { + const i = path.replace(/\/+$/, '').lastIndexOf('/'); + return i <= 0 ? '/' : path.slice(0, i); +} + +function shareTarget(share: NcShare): ShareTarget { + return { + path: share.path, + name: share.name, + type: share.itemType, + mime: share.mime, + writable: share.itemWritable, + }; +} + +/** + * Ansichten "Von mir geteilt" und "Mit mir geteilt" (quick-261009-dkv, D-04, D-17). Beide + * zeigen jede Freigabe einmal als dichte Liste; eigene Freigaben lassen sich im Teilen-Dialog + * aendern, eingehende verlassen, noch nicht angenommene annehmen oder ablehnen. "Oeffnen" + * springt in die Dateiansicht (`onOpen`). Freigaben anderer Arten stehen nur als Zahl da. + */ +export function SharesView({ + mode, + onOpen, + onExpired, +}: { + mode: 'byMe' | 'withMe'; + /** In die Dateiansicht wechseln: Ordnerpfad und ggf. der Name des Eintrags darin. */ + onOpen: (path: string, focusName?: string) => void; + onExpired: () => void; +}) { + const t = useTranslations('nextcloudFiles.sharesView'); + const tShare = useTranslations('nextcloudFiles.share'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const locale = useLocale(); + + const [shares, setShares] = useState([]); + const [pending, setPending] = useState([]); + const [hidden, setHidden] = useState(0); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [managing, setManaging] = useState(null); + const [leaving, setLeaving] = useState(null); + + const fail = useCallback( + (err: unknown) => { + const e = toErrorLike(err); + if (e.code === 'connectionExpired') { + onExpired(); + return; + } + setError(e); + }, + [onExpired], + ); + + const load = useCallback( + async (quiet = false) => { + if (!quiet) setLoading(true); + try { + if (mode === 'byMe') { + const out = await listMyShares(); + setShares(out.shares); + setPending([]); + setHidden(out.hidden); + setTruncated(out.truncated); + } else { + const out = await listReceivedShares(); + setShares(out.shares); + setPending(out.pending); + setHidden(out.hidden); + setTruncated(out.truncated); + } + setError(null); + } catch (err) { + fail(err); + } finally { + setLoading(false); + } + }, + [mode, fail], + ); + + useEffect(() => { + void load(); + }, [load]); + + const act = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await action(); + await load(true); + } catch (err) { + fail(err); + } finally { + setBusy(false); + } + }; + + const accessText = (share: NcShare) => tShare(`access.${share.access}`); + + /** Eigene Freigaben je Eintrag zusammengefasst. */ + const groups = useMemo(() => { + const map = new Map(); + for (const s of shares) map.set(s.path, [...(map.get(s.path) ?? []), s]); + return [...map.values()]; + }, [shares]); + + const recipientText = (share: NcShare): string => { + const base = + share.kind === 'link' + ? t('linkRecipient') + : share.kind === 'group' + ? t('groupRecipient', { name: share.shareWithName ?? share.shareWith ?? '' }) + : (share.shareWithName ?? share.shareWith ?? ''); + const details = [ + accessText(share), + share.kind === 'link' && share.hasPassword ? tShare('link.withPassword') : null, + share.expiration + ? tShare('people.expires', { date: dateText(share.expiration, locale) }) + : null, + ].filter(Boolean); + return `${base} (${details.join(', ')})`; + }; + + const detail = error ? ncMessageOf(error) : null; + + const rowClass = 'flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5'; + + const leaveRow = (share: NcShare) => + leaving === share.id && ( +
+ {t('leaveAria', { name: share.name })} +

{t('leaveConfirm', { name: share.name })}

+
+ + +
+
+ ); + + const receivedRow = (share: NcShare, isPending: boolean) => { + const isFolder = share.itemType === 'folder'; + return ( +
  • + +
    +

    {share.name}

    +

    + {[ + share.ownerName ? t('from', { name: share.ownerName }) : null, + accessText(share), + share.expiration + ? tShare('people.expires', { date: dateText(share.expiration, locale) }) + : null, + ] + .filter(Boolean) + .join(', ')} +

    +
    +
    + {isPending ? ( + <> + + + + ) : ( + <> + + + + )} +
    + {!isPending && leaveRow(share)} +
  • + ); + }; + + const empty = + !loading && + !error && + (mode === 'byMe' ? shares.length === 0 : shares.length === 0 && pending.length === 0); + + return ( +
    + {loading ? ( +

    + {t('loading')} +

    + ) : null} + + {error && ( +
    +

    {errorText(tCodes, error, locale)}

    + {detail && ( +

    {tCodes('ncDetail', { detail })}

    + )} + +
    + )} + + {empty && ( +

    + {mode === 'byMe' ? t('byMeEmpty') : t('withMeEmpty')} +

    + )} + + {mode === 'withMe' && pending.length > 0 && ( +
    +

    {t('pendingTitle')}

    +
      + {pending.map((s) => receivedRow(s, true))} +
    +
    + )} + + {mode === 'withMe' && shares.length > 0 && ( +
    + {pending.length > 0 && ( +

    {t('acceptedTitle')}

    + )} +
      + {shares.map((s) => receivedRow(s, false))} +
    +
    + )} + + {mode === 'byMe' && groups.length > 0 && ( +
      + {groups.map((group) => { + const first = group[0]; + const folder = parentOf(first.path); + return ( +
    • + +
      +

      + {first.name} + + {t('inFolder', { + folder: folder === '/' ? t('rootFolder') : folder, + })} + +

      +

      + {t('sharedWith', { list: group.map(recipientText).join(', ') })} +

      +
      +
      + + +
      +
    • + ); + })} +
    + )} + + {hidden > 0 && ( +

    {tShare('hidden', { count: hidden })}

    + )} + {truncated &&

    {t('truncated')}

    } + + {managing && ( + setManaging(null)} + onChanged={() => void load(true)} + onExpired={() => { + setManaging(null); + onExpired(); + }} + /> + )} +
    + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx index 176f23b..20495b7 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx @@ -40,6 +40,9 @@ const mockCancelFlow = vi.fn(); const mockDisconnect = vi.fn(); const mockListFolder = vi.fn(); const mockGetServerInfo = vi.fn(); +const mockSharePolicy = vi.fn(); +const mockListMine = vi.fn(); +const mockListReceived = vi.fn(); vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { const actual = await importOriginal(); @@ -56,6 +59,9 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args), listFolder: (...args: unknown[]) => mockListFolder(...args), getServerInfo: (...args: unknown[]) => mockGetServerInfo(...args), + getSharePolicy: (...args: unknown[]) => mockSharePolicy(...args), + listMyShares: (...args: unknown[]) => mockListMine(...args), + listReceivedShares: (...args: unknown[]) => mockListReceived(...args), }; }); @@ -102,6 +108,11 @@ beforeEach(() => { mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true }); mockDisconnect.mockReset(); mockGetServerInfo.mockReset().mockResolvedValue(serverInfo()); + mockSharePolicy.mockReset().mockResolvedValue({ enabled: true, groupsEnabled: true }); + mockListMine.mockReset().mockResolvedValue({ shares: [], hidden: 0, truncated: false }); + mockListReceived + .mockReset() + .mockResolvedValue({ shares: [], pending: [], hidden: 0, truncated: false }); mockListFolder.mockReset().mockResolvedValue({ path: '/', entries: [ @@ -589,6 +600,142 @@ describe('Dateiansicht auf der Seite', () => { }); }); +describe('Reiter fuer Freigaben (quick-261009-dkv)', () => { + const connectedStatus = () => status({ account: account() }); + const received = (over: Record = {}) => ({ + id: '40', + kind: 'user', + path: '/Ben-Ordner', + name: 'Ben-Ordner', + itemType: 'folder', + mime: null, + itemWritable: false, + permissions: 1, + access: 'view', + shareWith: 'anna', + shareWithName: 'Anna Müller', + ownerId: 'ben', + ownerName: 'Ben Beispiel', + canEdit: false, + canDelete: true, + expiration: null, + label: '', + url: null, + hasPassword: false, + target: '/Ben-Ordner', + sharedAt: null, + ...over, + }); + + it('verbunden, Benutzen: Dateien, Von mir geteilt, Mit mir geteilt - und kein Einstellungen', async () => { + mockCanManage = false; + mockGetStatus.mockResolvedValue(connectedStatus()); + render(); + expect(await screen.findByRole('button', { name: 'Von mir geteilt' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Mit mir geteilt' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Dateien' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull(); + }); + + it('Verwalter sehen zusaetzlich Einstellungen', async () => { + mockGetStatus.mockResolvedValue(connectedStatus()); + render(); + expect(await screen.findByRole('button', { name: 'Mit mir geteilt' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Einstellungen' })).toBeTruthy(); + }); + + it('nicht verbunden, Benutzen: weiterhin keine Reiterleiste', async () => { + mockCanManage = false; + render(); + expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Von mir geteilt' })).toBeNull(); + expect(mockSharePolicy).not.toHaveBeenCalled(); + }); + + it('Teilen in der Nextcloud ausgeschaltet: keine Freigabe-Reiter', async () => { + mockCanManage = false; + mockSharePolicy.mockResolvedValue({ enabled: false }); + mockGetStatus.mockResolvedValue(connectedStatus()); + render(); + await waitFor(() => expect(mockSharePolicy).toHaveBeenCalledTimes(1)); + await screen.findByTestId('nextcloud-files-browser'); + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Von mir geteilt' })).toBeNull(), + ); + }); + + it('die Regeln nicht lesbar: Reiter bleiben stehen', async () => { + mockCanManage = false; + mockSharePolicy.mockRejectedValue(new NextcloudFilesRequestError(502, 'nextcloudError', 'x')); + mockGetStatus.mockResolvedValue(connectedStatus()); + render(); + expect(await screen.findByRole('button', { name: 'Von mir geteilt' })).toBeTruthy(); + }); + + it('Von mir geteilt und Mit mir geteilt laden ihre Listen', async () => { + mockGetStatus.mockResolvedValue(connectedStatus()); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Von mir geteilt' })); + expect(await screen.findByTestId('nextcloud-files-shared-by-me')).toBeTruthy(); + await waitFor(() => expect(mockListMine).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/Sie haben noch nichts geteilt/)).toBeTruthy(); + // die Kontoleiste bleibt in den Freigabe-Reitern + expect(screen.getByText('Angemeldet als Anna Müller')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Mit mir geteilt' })); + await waitFor(() => expect(mockListReceived).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('Mit Ihnen hat noch niemand etwas geteilt.')).toBeTruthy(); + }); + + it('"Öffnen" wechselt zu Dateien und startet im Zielordner; ein Reiterklick loescht den Start', async () => { + mockGetStatus.mockResolvedValue(connectedStatus()); + mockListReceived.mockResolvedValue({ + shares: [received()], + pending: [], + hidden: 0, + truncated: false, + }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Mit mir geteilt' })); + fireEvent.click(await screen.findByRole('button', { name: '„Ben-Ordner“ öffnen' })); + expect(await screen.findByTestId('nextcloud-files-browser')).toBeTruthy(); + await waitFor(() => expect(mockListFolder).toHaveBeenCalledWith('/Ben-Ordner')); + + // wieder in die Ansicht und mit dem Reiter "Dateien" zurueck: Start ist geloescht + fireEvent.click(screen.getByRole('button', { name: 'Mit mir geteilt' })); + await screen.findByRole('button', { name: '„Ben-Ordner“ öffnen' }); + mockListFolder.mockClear(); + // die Dateiansicht spiegelt ihren Ordner in ?path=; ohne diese Spur zeigt erst der geloeschte Start den Unterschied + window.history.replaceState(null, '', '/'); + fireEvent.click(screen.getByRole('button', { name: 'Dateien' })); + await waitFor(() => expect(mockListFolder).toHaveBeenCalled()); + expect(mockListFolder).not.toHaveBeenCalledWith('/Ben-Ordner'); + }); + + it('eine Datei oeffnet den Ordner darueber', async () => { + mockGetStatus.mockResolvedValue(connectedStatus()); + mockListReceived.mockResolvedValue({ + shares: [ + received({ + id: '41', + name: 'Plan.pdf', + itemType: 'file', + mime: 'application/pdf', + path: '/Ben/Plan.pdf', + target: '/Ben/Plan.pdf', + }), + ], + pending: [], + hidden: 0, + truncated: false, + }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Mit mir geteilt' })); + fireEvent.click(await screen.findByRole('button', { name: '„Plan.pdf“ öffnen' })); + await waitFor(() => expect(mockListFolder).toHaveBeenCalledWith('/Ben')); + }); +}); + describe('Kennung der Nextcloud (Aufgabe 6)', () => { it('Anmeldebildschirm zeigt Name und Rechnername, die Kachel in der Themenfarbe, ohne Logo den Anfangsbuchstaben', async () => { render(); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx index 6167ded..2704d5d 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx @@ -8,6 +8,8 @@ import { PageHeader } from '@/components/layout/page-header'; import { getNextcloudFilesStatus, getServerInfo, + getSharePolicy, + type NcSharePolicy, type NextcloudFilesStatus, type NextcloudServerInfo, } from '@/lib/nextcloud-files-api'; @@ -17,11 +19,20 @@ import { AccountBar } from './components/AccountBar'; import { ConnectPanel } from './components/ConnectPanel'; import { FileBrowser } from './components/FileBrowser'; import { SettingsTab } from './components/SettingsTab'; +import { SharesView } from './components/SharesView'; -type TabId = 'files' | 'settings'; +type TabId = 'files' | 'sharedByMe' | 'sharedWithMe' | 'settings'; + +/** Startordner der Dateiansicht nach "Öffnen" in einer Freigabe-Ansicht. */ +interface BrowserStart { + path: string; + focus?: string; +} /** * Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera. + * Verbundene Benutzer haben zusaetzlich die Reiter "Von mir geteilt" und "Mit mir geteilt" + * (quick-261009-dkv); sie fehlen nur, wenn die Nextcloud das Teilen ausgeschaltet hat. * Den Reiter "Einstellungen" sehen Administratoren und Benutzer mit der * Freigabestufe Verwalten; die Adresse der Nextcloud AENDERN darf nur ein * Administrator (CR-02). Alle anderen sehen nur den Reiter "Dateien" ohne @@ -41,6 +52,8 @@ export default function NextcloudFilesPage() { const [statusError, setStatusError] = useState(false); const [tab, setTab] = useState('files'); const [serverInfo, setServerInfo] = useState(null); + const [sharePolicy, setSharePolicy] = useState(null); + const [browserStart, setBrowserStart] = useState(null); const reloadStatus = useCallback(async () => { try { @@ -81,10 +94,49 @@ export default function NextcloudFilesPage() { const account = status?.account ?? null; const connected = account?.connected === true; + // Freigaberegeln einmal lesen, sobald das Konto verbunden ist. Ein Fehler (ausser einer + // abgelaufenen Verbindung) laesst Reiter und "Teilen" stehen: die API meldet es dann selbst. + useEffect(() => { + if (!connected) { + setSharePolicy(null); + return; + } + let cancelled = false; + getSharePolicy() + .then((p) => { + if (!cancelled) setSharePolicy(p); + }) + .catch((err) => { + if (cancelled) return; + setSharePolicy(null); + if ((err as { code?: string })?.code === 'connectionExpired') void reloadStatus(); + }); + return () => { + cancelled = true; + }; + }, [connected, reloadStatus]); + + const sharingEnabled = sharePolicy?.enabled !== false; + const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }]; + if (connected && sharingEnabled) { + tabs.push({ id: 'sharedByMe', label: t('tabs.sharedByMe') }); + tabs.push({ id: 'sharedWithMe', label: t('tabs.sharedWithMe') }); + } if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') }); const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files'; + const pickTab = (id: TabId) => { + setBrowserStart(null); + setTab(id); + }; + /** "Öffnen" in einer Freigabe-Ansicht: zur Dateiansicht wechseln und dort starten. */ + const openInFiles = (path: string, focus?: string) => { + setBrowserStart({ path, focus }); + setTab('files'); + }; + const refreshAfterExpiry = () => void reloadStatus(); + return (
    )} - {canManage && } + {tabs.length > 1 && } {activeTab === 'files' && status && !status.configured && (

    @@ -116,7 +168,7 @@ export default function NextcloudFilesPage() { {isAdmin && canManage && (