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) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:19:39 +02:00
parent fe429dbe01
commit c3adf851b8
17 changed files with 2452 additions and 37 deletions
@@ -138,9 +138,7 @@ beforeEach(() => {
minSearchLength: 0,
passwordMinLength: 10,
});
mockSharesForPath
.mockReset()
.mockImplementation(async (p: string) => ({
mockSharesForPath.mockReset().mockImplementation(async (p: string) => ({
path: p,
shares: [],
hidden: 0,
@@ -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(
<FileBrowser
serverUrl={null}
onExpired={onExpired}
initialPath="/Projekte"
initialFocus="Plan.xlsx"
/>,
);
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(<FileBrowser serverUrl={null} onExpired={onExpired} initialPath="/Projekte" />);
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' });
@@ -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);
}, []);
@@ -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<ShareAccessChoice>(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<EditPassword>(
editing && required && !hasPassword ? 'change' : 'keep',
);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordCopied, setPasswordCopied] = useState(false);
const passwordInput = useRef<HTMLInputElement>(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 (
<form
onSubmit={submit}
aria-label={editing ? t('titleEdit') : t('titleCreate')}
className="mt-3 space-y-4 rounded border border-border bg-well/40 p-3"
>
<fieldset className="space-y-1.5">
<legend className="text-sm font-medium text-foreground">{t('accessLegend')}</legend>
{options.map((o) => (
<label key={o} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={`${ids}-access`}
value={o}
checked={access === o}
disabled={busy}
onChange={() => setAccess(o)}
className="mt-0.5 h-4 w-4"
/>
<span>
<span className="text-foreground">{tAccess(o)}</span>
<span className="block text-xs text-muted-foreground">
{t(`accessHint.${o === 'edit' && target.type === 'file' ? 'editFile' : o}`)}
</span>
</span>
</label>
))}
</fieldset>
<div className="space-y-1.5">
{required ? (
<p className="text-sm font-medium text-foreground">{passwordLabel}</p>
) : editing && hasPassword ? (
<p className="text-sm font-medium text-foreground">{t('passwordLabel')}</p>
) : (
<label className="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={editing ? editPassword === 'change' : createPasswordOn}
disabled={busy}
onChange={(e) => {
if (editing) setEditPassword(e.target.checked ? 'change' : 'keep');
else setCreatePasswordOn(e.target.checked);
if (!e.target.checked) setPassword('');
}}
className="h-4 w-4"
/>
{t('protect')}
</label>
)}
{editing && hasPassword && (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">
{editPassword === 'remove' ? t('willRemove') : t('hasPassword')}
</span>
{editPassword !== 'change' && (
<button
type="button"
className="btn btn-subtle"
disabled={busy}
onClick={() => {
setEditPassword('change');
setPassword('');
}}
>
{t('change')}
</button>
)}
{!required && editPassword !== 'remove' && (
<button
type="button"
className="btn btn-subtle"
disabled={busy}
onClick={() => {
setEditPassword('remove');
setPassword('');
}}
>
{t('remove')}
</button>
)}
{editPassword !== 'keep' && (
<button
type="button"
className="btn btn-subtle"
disabled={busy}
onClick={() => {
setEditPassword('keep');
setPassword('');
}}
>
{t('keep')}
</button>
)}
</div>
)}
{passwordFieldVisible && (
<div className="space-y-1.5">
<label htmlFor={`${ids}-pw`} className="sr-only">
{passwordLabel}
</label>
<div className="flex flex-wrap gap-2 sm:flex-nowrap">
<input
ref={passwordInput}
id={`${ids}-pw`}
type={showPassword ? 'text' : 'password'}
value={password}
required
autoComplete="new-password"
spellCheck={false}
disabled={busy}
onChange={(e) => {
setPassword(e.target.value);
setPasswordCopied(false);
}}
className={`${FIELD} min-w-0 flex-1 font-mono`}
/>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={generate}
>
{t('generate')}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-pressed={showPassword}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? t('hide') : t('show')}
</button>
<button
type="button"
className="btn btn-subtle"
disabled={busy || password === ''}
onClick={() => void copyPassword()}
>
{passwordCopied ? t('passwordCopied') : t('copyPassword')}
</button>
{policy.passwordMinLength !== null && (
<span>{t('minLength', { count: policy.passwordMinLength })}</span>
)}
</div>
</div>
)}
</div>
<div className="space-y-1.5">
{rule.required ? (
<label htmlFor={`${ids}-date`} className="block text-sm font-medium text-foreground">
{expiryLabel}
</label>
) : (
<label className="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={rule.defaultDate !== null ? !expiryOn : expiryOn}
disabled={busy}
onChange={(e) => {
const on = rule.defaultDate !== null ? !e.target.checked : e.target.checked;
setExpiryOn(on);
if (on && expireDate === '') setExpireDate(rule.defaultDate ?? '');
}}
className="h-4 w-4"
/>
{rule.defaultDate !== null ? t('expiryNone') : t('expirySet')}
</label>
)}
{(rule.required || expiryOn) && (
<>
{!rule.required && (
<label htmlFor={`${ids}-date`} className="sr-only">
{expiryLabel}
</label>
)}
<input
id={`${ids}-date`}
type="date"
value={expireDate}
min={rule.minDate}
max={rule.maxDate ?? undefined}
required
disabled={busy}
onChange={(e) => setExpireDate(e.target.value)}
className={FIELD}
/>
{rule.maxDate !== null && (
<p className="text-xs text-muted-foreground">
{t('expiryMax', { date: dateText(rule.maxDate, locale) })}
</p>
)}
{dateOutOfRange && (
<p role="alert" className="text-xs text-status-down-fg">
{t('expiryOutOfRange')}
</p>
)}
</>
)}
</div>
<div className="space-y-1">
<label htmlFor={`${ids}-label`} className="block text-sm font-medium text-foreground">
{t('labelLabel')}
</label>
<input
id={`${ids}-label`}
type="text"
value={label}
maxLength={255}
disabled={busy}
onChange={(e) => setLabel(e.target.value)}
className={`${FIELD} w-full`}
/>
<p className="text-xs text-muted-foreground">{t('labelHint')}</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={onCancel}>
{t('cancel')}
</button>
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
{editing ? t('submitSave') : t('submitCreate')}
</button>
</div>
</form>
);
}
@@ -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> = {}): NcShare {
return share({
id: '31',
kind: 'link',
shareWith: null,
shareWithName: null,
url: URL31,
label: '',
...over,
});
}
function policyWith(over: Partial<NcSharePolicy['links']>, extra: Partial<NcSharePolicy> = {}) {
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);
});
});
@@ -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<ErrorLike | null>(null);
const [errorSection, setErrorSection] = useState<Section>('people');
const [busy, setBusy] = useState(false);
const [linkPanel, setLinkPanel] = useState<LinkPanel | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(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<string | null>(null);
const urlInputs = useRef(new Map<string, HTMLInputElement>());
const [term, setTerm] = useState('');
const [results, setResults] = useState<NcSharee[]>([]);
const [searchedFor, setSearchedFor] = useState<string | null>(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<void>) => {
const run = async (section: Section, action: () => Promise<void>) => {
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<HTMLInputElement>) => {
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 ? (
<div role="alert" className="mt-3 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>
) : null;
return (
<Dialog
wide
@@ -435,18 +518,210 @@ export function ShareDialog({
{hidden > 0 && (
<p className="mt-2 text-xs text-muted-foreground">{t('hidden', { count: hidden })}</p>
)}
{errorBlock('people')}
</section>
<section aria-labelledby={`${ids}-link`}>
<h3 id={`${ids}-link`} className="text-sm font-semibold text-foreground">
{t('link.title')}
</h3>
{!policy?.links.enabled ? (
<p className="mt-2 text-sm text-muted-foreground">{tCodes('linkSharingDisabled')}</p>
) : (
<>
{links.length === 0 && linkPanel === null && (
<p className="mt-2 text-sm text-muted-foreground">{t('link.empty')}</p>
)}
{links.length > 0 && (
<ul className="mt-3 divide-y divide-border/70 rounded border border-border">
{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 (
<li key={share.id} className="px-3 py-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
aria-hidden="true"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-well text-muted-foreground"
>
<LinkIcon size={16} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{name}</p>
<p className="text-xs text-muted-foreground">
{[
t(`access.${share.access}`),
share.hasPassword ? t('link.withPassword') : null,
share.expiration
? t('people.expires', {
date: dateText(share.expiration, locale),
})
: null,
]
.filter(Boolean)
.join(', ')}
</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-label={t('link.editAria', { name })}
onClick={() => {
setConfirmId(null);
setLinkPanel(editingThis ? null : { mode: 'edit', id: share.id });
}}
>
{t('link.edit')}
</button>
<button
type="button"
className="btn btn-subtle"
disabled={busy || !share.canDelete}
aria-label={t('link.deleteAria', { name })}
onClick={() => {
setLinkPanel(null);
setConfirmId(confirming ? null : share.id);
}}
>
{t('link.delete')}
</button>
</div>
</div>
{share.url && (
<div className="mt-2 flex flex-wrap gap-2 sm:flex-nowrap">
<input
ref={(el) => {
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"
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => void copyLink(share)}
>
<CopyIcon size={14} />
<span>
{copiedId === share.id ? t('link.copied') : t('link.copy')}
</span>
</button>
</div>
)}
<p role="status" aria-live="polite" className="sr-only">
{copiedId === share.id ? t('link.copied') : ''}
</p>
{revealed?.id === share.id && (
<div className="mt-2 rounded border border-border bg-well/40 p-2.5 text-sm">
<p className="text-muted-foreground">{t('link.passwordNote')}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className="sr-only">{t('link.passwordOfLink')}</span>
<code className="break-all rounded bg-background px-2 py-1 font-mono text-xs text-foreground">
{revealed.password}
</code>
<button
type="button"
className="btn btn-subtle"
onClick={() => void copyRevealed()}
>
{revealedCopied
? t('link.passwordCopied')
: t('link.copyPassword')}
</button>
<button
type="button"
className="btn btn-subtle"
onClick={() => setRevealed(null)}
>
{t('link.passwordDone')}
</button>
</div>
</div>
)}
{confirming && (
<fieldset className="mt-2 min-w-0 rounded border border-border bg-well/40 p-2.5 text-sm">
<legend className="sr-only">{t('link.deleteAria', { name })}</legend>
<p className="text-foreground">
{t('link.deleteConfirm', { name: target.name })}
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => setConfirmId(null)}
>
{t('link.deleteNo')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void removeLink(share)}
>
{t('link.deleteYes')}
</button>
</div>
</fieldset>
)}
{editingThis && policy && (
<LinkShareForm
target={target}
policy={policy}
share={share}
busy={busy}
onSubmit={submitLink}
onCancel={() => setLinkPanel(null)}
/>
)}
</li>
);
})}
</ul>
)}
{linkPanel?.mode === 'create' && policy ? (
<LinkShareForm
target={target}
policy={policy}
busy={busy}
onSubmit={submitLink}
onCancel={() => setLinkPanel(null)}
/>
) : (
(links.length === 0 || policy?.links.multipleLinks) && (
<button
type="button"
className="btn btn-secondary mt-3"
disabled={busy}
onClick={() => {
setConfirmId(null);
setLinkPanel({ mode: 'create' });
}}
>
{links.length === 0 ? t('link.create') : t('link.createMore')}
</button>
)
)}
</>
)}
{errorBlock('link')}
</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>
)}
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
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> = {}): 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<NcShare> = {}) =>
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<typeof vi.fn>;
let onExpired: ReturnType<typeof vi.fn>;
function mount(mode: 'byMe' | 'withMe') {
return render(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<SharesView mode={mode} onOpen={onOpen} onExpired={onExpired} />
</NextIntlClientProvider>,
);
}
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));
});
});
@@ -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<NcShare[]>([]);
const [pending, setPending] = useState<NcShare[]>([]);
const [hidden, setHidden] = useState(0);
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorLike | null>(null);
const [busy, setBusy] = useState(false);
const [managing, setManaging] = useState<ShareTarget | null>(null);
const [leaving, setLeaving] = useState<string | null>(null);
const fail = useCallback(
(err: unknown) => {
const e = toErrorLike(err);
if (e.code === 'connectionExpired') {
onExpired();
return;
}
setError(e);
},
[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<void>) => {
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<string, NcShare[]>();
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 && (
<fieldset className="m-0 min-w-0 basis-full rounded border border-border bg-well/40 p-2.5 text-sm">
<legend className="sr-only">{t('leaveAria', { name: share.name })}</legend>
<p className="text-foreground">{t('leaveConfirm', { name: share.name })}</p>
<div className="mt-2 flex gap-2">
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => setLeaving(null)}
>
{t('leaveNo')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() =>
void act(async () => {
await deleteShare(share.id);
setLeaving(null);
})
}
>
{t('leaveYes')}
</button>
</div>
</fieldset>
);
const receivedRow = (share: NcShare, isPending: boolean) => {
const isFolder = share.itemType === 'folder';
return (
<li key={share.id} className={rowClass}>
<TypeTile entry={{ name: share.name, type: share.itemType, mime: share.mime }} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{share.name}</p>
<p className="truncate text-xs text-muted-foreground">
{[
share.ownerName ? t('from', { name: share.ownerName }) : null,
accessText(share),
share.expiration
? tShare('people.expires', { date: dateText(share.expiration, locale) })
: null,
]
.filter(Boolean)
.join(', ')}
</p>
</div>
<div className="flex flex-wrap items-center gap-1">
{isPending ? (
<>
<button
type="button"
className="btn btn-primary"
disabled={busy}
aria-label={t('acceptAria', { name: share.name })}
onClick={() => void act(() => acceptShare(share.id).then(() => undefined))}
>
{t('accept')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
aria-label={t('declineAria', { name: share.name })}
onClick={() => void act(() => deleteShare(share.id).then(() => undefined))}
>
{t('decline')}
</button>
</>
) : (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
aria-label={t('openAria', { name: share.name })}
onClick={() =>
isFolder ? onOpen(share.target) : onOpen(parentOf(share.target), share.name)
}
>
{t('open')}
</button>
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-label={t('leaveAria', { name: share.name })}
onClick={() => setLeaving(leaving === share.id ? null : share.id)}
>
{t('leave')}
</button>
</>
)}
</div>
{!isPending && leaveRow(share)}
</li>
);
};
const empty =
!loading &&
!error &&
(mode === 'byMe' ? shares.length === 0 : shares.length === 0 && pending.length === 0);
return (
<section data-testid={`nextcloud-files-shares-${mode}`} className="space-y-4">
{loading ? (
<p role="status" className="py-4 text-sm text-muted-foreground">
{t('loading')}
</p>
) : null}
{error && (
<div role="alert" className="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>
)}
<button type="button" className="btn btn-subtle mt-1" onClick={() => void load()}>
{t('reload')}
</button>
</div>
)}
{empty && (
<p className="rounded border border-border bg-card px-4 py-6 text-sm text-muted-foreground">
{mode === 'byMe' ? t('byMeEmpty') : t('withMeEmpty')}
</p>
)}
{mode === 'withMe' && pending.length > 0 && (
<div>
<h2 className="mb-2 text-sm font-semibold text-foreground">{t('pendingTitle')}</h2>
<ul className="divide-y divide-border/70 rounded border border-border bg-card">
{pending.map((s) => receivedRow(s, true))}
</ul>
</div>
)}
{mode === 'withMe' && shares.length > 0 && (
<div>
{pending.length > 0 && (
<h2 className="mb-2 text-sm font-semibold text-foreground">{t('acceptedTitle')}</h2>
)}
<ul className="divide-y divide-border/70 rounded border border-border bg-card">
{shares.map((s) => receivedRow(s, false))}
</ul>
</div>
)}
{mode === 'byMe' && groups.length > 0 && (
<ul className="divide-y divide-border/70 rounded border border-border bg-card">
{groups.map((group) => {
const first = group[0];
const folder = parentOf(first.path);
return (
<li key={first.path} className={rowClass}>
<TypeTile entry={{ name: first.name, type: first.itemType, mime: first.mime }} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{first.name}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{t('inFolder', {
folder: folder === '/' ? t('rootFolder') : folder,
})}
</span>
</p>
<p className="text-xs text-muted-foreground">
{t('sharedWith', { list: group.map(recipientText).join(', ') })}
</p>
</div>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className="btn btn-secondary"
disabled={busy}
aria-label={t('manageAria', { name: first.name })}
onClick={() => setManaging(shareTarget(first))}
>
{t('manage')}
</button>
<button
type="button"
className="btn btn-subtle"
disabled={busy}
aria-label={t('showInFolderAria', { name: first.name })}
onClick={() => onOpen(folder, first.name)}
>
{t('showInFolder')}
</button>
</div>
</li>
);
})}
</ul>
)}
{hidden > 0 && (
<p className="text-xs text-muted-foreground">{tShare('hidden', { count: hidden })}</p>
)}
{truncated && <p className="text-xs text-muted-foreground">{t('truncated')}</p>}
{managing && (
<ShareDialog
target={managing}
onClose={() => setManaging(null)}
onChanged={() => void load(true)}
onExpired={() => {
setManaging(null);
onExpired();
}}
/>
)}
</section>
);
}
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
@@ -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<string, unknown> = {}) => ({
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
@@ -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<TabId>('files');
const [serverInfo, setServerInfo] = useState<NextcloudServerInfo | null>(null);
const [sharePolicy, setSharePolicy] = useState<NcSharePolicy | null>(null);
const [browserStart, setBrowserStart] = useState<BrowserStart | null>(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 (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader
@@ -92,7 +144,7 @@ export default function NextcloudFilesPage() {
title={t('title')}
description={t('description')}
actions={
activeTab === 'files' && status?.configured && account && connected ? (
activeTab !== 'settings' && status?.configured && account && connected ? (
<AccountBar
account={account}
host={status.host}
@@ -107,7 +159,7 @@ export default function NextcloudFilesPage() {
{t('errors.loadStatus')}
</p>
)}
{canManage && <TabBar tabs={tabs} active={activeTab} onChange={setTab} />}
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} onChange={pickTab} />}
{activeTab === 'files' && status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
@@ -116,7 +168,7 @@ export default function NextcloudFilesPage() {
{isAdmin && canManage && (
<button
type="button"
onClick={() => setTab('settings')}
onClick={() => pickTab('settings')}
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{t('notConfigured.goToSettings')}
@@ -128,7 +180,13 @@ export default function NextcloudFilesPage() {
<section data-testid="nextcloud-files-main" className="space-y-4">
{connected ? (
<div data-testid="nextcloud-files-browser">
<FileBrowser serverUrl={status.serverUrl} onExpired={() => void reloadStatus()} />
<FileBrowser
serverUrl={status.serverUrl}
onExpired={refreshAfterExpiry}
sharingEnabled={sharingEnabled}
initialPath={browserStart?.path}
initialFocus={browserStart?.focus}
/>
</div>
) : (
<ConnectPanel
@@ -140,6 +198,16 @@ export default function NextcloudFilesPage() {
)}
</section>
)}
{connected && activeTab === 'sharedByMe' && (
<div data-testid="nextcloud-files-shared-by-me">
<SharesView mode="byMe" onOpen={openInFiles} onExpired={refreshAfterExpiry} />
</div>
)}
{connected && activeTab === 'sharedWithMe' && (
<div data-testid="nextcloud-files-shared-with-me">
<SharesView mode="withMe" onOpen={openInFiles} onExpired={refreshAfterExpiry} />
</div>
)}
{activeTab === 'settings' && canManage && (
<SettingsTab onChanged={() => void reloadStatus()} />
)}
@@ -0,0 +1,21 @@
/**
* Text in die Zwischenablage legen (quick-261009-dkv). Zuerst die moderne Schnittstelle
* (`navigator.clipboard`, nur in sicheren Kontexten); scheitert sie, wird der Text im uebergebenen
* Eingabefeld markiert, damit der Benutzer mit Strg+C kopieren kann. Rueckgabe: `true` wenn der
* Text wirklich in der Zwischenablage liegt, sonst `false` (dann ist er markiert).
*/
export async function copyText(text: string, fallback?: HTMLInputElement | null): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// unten: markieren
}
if (fallback) {
fallback.focus();
fallback.select();
}
return false;
}
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { NcSharePolicy } from '@/lib/nextcloud-files-api';
import type { NcShare, NcSharePolicy } from '@/lib/nextcloud-files-api';
import {
accessOptions,
addDays,
expiryRule,
generatePassword,
linkPasswordMode,
linkUpdateDiff,
type RandomFill,
type ShareTarget,
targetFromEntry,
todayLocal,
} from './share-policy';
const POLICY: NcSharePolicy = {
@@ -65,6 +70,8 @@ describe('accessOptions', () => {
const noUpload = { ...POLICY, links: { ...POLICY.links, uploadAllowed: false } };
expect(accessOptions(folder(true), 'link', noUpload)).toEqual(['view']);
expect(accessOptions(folder(true), 'link', null)).toEqual(['view']);
// ein Eintrag ohne Schreibrecht kann auch nicht zum Hochladen freigegeben werden
expect(accessOptions(folder(false), 'link', POLICY)).toEqual(['view']);
});
});
@@ -103,3 +110,123 @@ describe('generatePassword', () => {
expect(generatePassword(null)).not.toBe(generatePassword(null));
});
});
const withLinks = (over: Partial<NcSharePolicy['links']>): NcSharePolicy => ({
...POLICY,
links: { ...POLICY.links, ...over },
});
describe('linkPasswordMode', () => {
it('verlangt, vorgeschlagen oder freiwillig', () => {
expect(linkPasswordMode(withLinks({ passwordRequired: true }))).toBe('required');
expect(linkPasswordMode(withLinks({ passwordSuggested: true }))).toBe('suggested');
expect(linkPasswordMode(POLICY)).toBe('optional');
expect(linkPasswordMode(null)).toBe('optional');
});
});
describe('Datumshilfen', () => {
it('addDays rechnet reiner Kalender: Jahreswechsel, Schaltjahr, Monatsende', () => {
expect(addDays('2026-12-28', 7)).toBe('2027-01-04');
expect(addDays('2028-02-28', 1)).toBe('2028-02-29');
expect(addDays('2027-02-28', 1)).toBe('2027-03-01');
expect(addDays('2026-10-09', 0)).toBe('2026-10-09');
expect(addDays('2026-10-09', 30)).toBe('2026-11-08');
});
it('todayLocal nimmt den lokalen Kalendertag, nicht UTC', () => {
expect(todayLocal(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05');
expect(todayLocal(new Date(2026, 11, 31, 0, 1))).toBe('2026-12-31');
});
});
describe('expiryRule', () => {
it('verlangt mit 7 Tagen: Pflicht, Vorbelegung und Hoechstdatum heute plus 7', () => {
expect(
expiryRule(withLinks({ expiryEnforced: true, expiryDefaultDays: 7 }), '2026-10-09'),
).toEqual({
required: true,
defaultDate: '2026-10-16',
maxDate: '2026-10-16',
minDate: '2026-10-09',
});
});
it('Vorgabe 7 Tage, nicht verlangt: Vorbelegung, aber keine Pflicht und kein Hoechstdatum', () => {
expect(expiryRule(withLinks({ expiryDefaultDays: 7 }), '2026-10-09')).toEqual({
required: false,
defaultDate: '2026-10-16',
maxDate: null,
minDate: '2026-10-09',
});
});
it('ohne Regel: weder Vorbelegung noch Hoechstdatum; verlangt ohne Tage: Pflicht ohne Hoechstdatum', () => {
expect(expiryRule(POLICY, '2026-10-09')).toEqual({
required: false,
defaultDate: null,
maxDate: null,
minDate: '2026-10-09',
});
expect(expiryRule(withLinks({ expiryEnforced: true }), '2026-10-09')).toMatchObject({
required: true,
defaultDate: null,
maxDate: null,
});
expect(expiryRule(null, '2026-10-09').required).toBe(false);
});
});
describe('linkUpdateDiff', () => {
const share = {
access: 'view',
expiration: '2026-12-01',
label: 'Kunde',
hasPassword: true,
} as NcShare;
it('liefert nur Geaendertes', () => {
expect(
linkUpdateDiff(share, {
access: 'view',
password: null,
expireDate: '2026-12-01',
label: 'Kunde',
}),
).toEqual({});
expect(
linkUpdateDiff(share, {
access: 'upload',
password: null,
expireDate: '2026-12-01',
label: 'Kunde',
}),
).toEqual({ access: 'upload' });
expect(
linkUpdateDiff(share, { access: 'view', password: 'Neu', expireDate: '', label: 'Angebot' }),
).toEqual({ password: 'Neu', expireDate: '', label: 'Angebot' });
expect(
linkUpdateDiff(share, {
access: 'view',
password: '',
expireDate: '2026-12-01',
label: 'Kunde',
}),
).toEqual({ password: '' });
});
it('ein Link ohne Ablauf vergleicht mit der leeren Zeichenkette', () => {
const open = { ...share, expiration: null } as NcShare;
expect(
linkUpdateDiff(open, { access: 'view', password: null, expireDate: '', label: 'Kunde' }),
).toEqual({});
expect(
linkUpdateDiff(open, {
access: 'view',
password: null,
expireDate: '2027-01-01',
label: 'Kunde',
}),
).toEqual({ expireDate: '2027-01-01' });
});
});
@@ -1,4 +1,4 @@
import type { NcEntry, NcSharePolicy } from '@/lib/nextcloud-files-api';
import type { NcEntry, NcShare, NcSharePolicy, UpdateShareInput } from '@/lib/nextcloud-files-api';
/**
* Reine Hilfen fuer das Teilen (quick-261009-dkv): was der Dialog anbietet, wird hier aus dem
@@ -44,8 +44,8 @@ export function accessOptions(
): ShareAccessChoice[] {
const options: ShareAccessChoice[] = ['view'];
if (audience === 'link') {
if (!policy?.links.uploadAllowed) return options;
if (target.writable) options.push('edit');
if (!policy?.links.uploadAllowed || !target.writable) return options;
options.push('edit');
if (target.type === 'folder') options.push('upload');
return options;
}
@@ -53,6 +53,72 @@ export function accessOptions(
return options;
}
// --- Link-Regeln: Passwort und Ablauf ------------------------------------------------------------
export type LinkPasswordMode = 'required' | 'suggested' | 'optional';
/** Verlangt die Nextcloud ein Passwort, schlaegt sie nur eins vor, oder ist es freiwillig? */
export function linkPasswordMode(policy: NcSharePolicy | null): LinkPasswordMode {
if (policy?.links.passwordRequired) return 'required';
if (policy?.links.passwordSuggested) return 'suggested';
return 'optional';
}
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
/** Heutiges Datum nach der Uhr des Benutzers als `YYYY-MM-DD` (nicht UTC). */
export function todayLocal(now: Date = new Date()): string {
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
/** Reine Kalenderrechnung (UTC, keine Sommerzeit-Fallen): `date` plus `n` Tage. */
export function addDays(date: string, n: number): string {
const [y, m, d] = date.split('-').map(Number);
const t = new Date(Date.UTC(y, m - 1, d + n));
return `${pad(t.getUTCFullYear(), 4)}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
}
export interface ExpiryRule {
/** Die Nextcloud verlangt ein Ablaufdatum. */
required: boolean;
/** Vorbelegung (heute plus die Tage der Nextcloud) oder null. */
defaultDate: string | null;
/** Spaetestes erlaubtes Datum (nur wenn verlangt) oder null. */
maxDate: string | null;
/** Fruehestes Datum: heute. */
minDate: string;
}
export function expiryRule(policy: NcSharePolicy | null, today: string): ExpiryRule {
const days = policy?.links.expiryDefaultDays ?? null;
const required = policy?.links.expiryEnforced === true;
const target = days === null ? null : addDays(today, days);
return {
required,
defaultDate: target,
maxDate: required ? target : null,
minDate: today,
};
}
/** Eingaben des Link-Formulars beim Aendern. `password`: null = behalten, '' = entfernen. */
export interface LinkFormValues {
access: ShareAccessChoice;
password: string | null;
expireDate: string;
label: string;
}
/** Nur die Felder, die sich gegenueber der vorhandenen Freigabe geaendert haben. */
export function linkUpdateDiff(share: NcShare, form: LinkFormValues): UpdateShareInput {
const diff: UpdateShareInput = {};
if (form.access !== share.access) diff.access = form.access;
if (form.password !== null) diff.password = form.password;
if (form.expireDate !== (share.expiration ?? '')) diff.expireDate = form.expireDate;
if (form.label !== share.label) diff.label = form.label;
return diff;
}
// --- Passwort erzeugen --------------------------------------------------------------------------
/** Ohne Zeichen, die sich ähneln (0 O o, 1 l I). */
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
acceptShare,
checkDownload,
checkZip,
createFolder,
@@ -9,6 +10,8 @@ import {
downloadUrl,
getSharePolicy,
listFolder,
listMyShares,
listReceivedShares,
listSharesForPath,
moveEntry,
NextcloudFilesRequestError,
@@ -226,4 +229,43 @@ describe('nextcloud-files-api — Teilen', () => {
expect(err.code).toBe('shareRejected');
expect(err.extra).toEqual({ ncMessage: 'Nein' });
});
it('createShare fuer einen Link sendet Passwort und Ablauf nur im JSON-Koerper', async () => {
const fetchMock = mockFetch(201, { id: '2' });
await createShare({
path: '/P',
kind: 'link',
access: 'view',
password: 'Geheim-1',
expireDate: '',
label: 'Kunde',
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/shares`);
expect(init.body).toBe(
'{"path":"/P","kind":"link","access":"view","password":"Geheim-1","expireDate":"","label":"Kunde"}',
);
});
it('listMyShares und listReceivedShares: GET auf die festen Pfade', async () => {
const mine = mockFetch(200, { shares: [], hidden: 0, truncated: false });
await listMyShares();
expect((mine.mock.calls[0] as [string, RequestInit])[0]).toBe(
`${API}/modules/nextcloud-files/shares/mine`,
);
const received = mockFetch(200, { shares: [], pending: [], hidden: 0, truncated: false });
await listReceivedShares();
expect((received.mock.calls[0] as [string, RequestInit])[0]).toBe(
`${API}/modules/nextcloud-files/shares/received`,
);
});
it('acceptShare: POST auf shares/<Kennung>/accept ohne Koerper', async () => {
const fetchMock = mockFetch(200, { accepted: true });
await acceptShare('17');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/shares/17/accept`);
expect(init.method).toBe('POST');
expect(init.body).toBeUndefined();
});
});
+40 -2
View File
@@ -384,15 +384,38 @@ export interface NcShareList {
truncated: boolean;
}
/**
* Neue Freigabe. Bei Links: `password` (nie in einer Adresse), `expireDate` (`YYYY-MM-DD` oder
* `''` fuer "ohne Ablauf") und `label`; bei Personen und Gruppen `shareWith`.
*/
export interface CreateShareInput {
path: string;
kind: 'user' | 'group';
shareWith: string;
kind: NcShareKind;
shareWith?: string;
access: 'view' | 'edit' | 'upload';
password?: string;
expireDate?: string;
label?: string;
}
/** Leere Zeichenkette heisst: Passwort entfernen, Ablaufdatum entfernen, Bezeichnung leeren. */
export interface UpdateShareInput {
access?: 'view' | 'edit' | 'upload';
password?: string;
expireDate?: string;
label?: string;
}
/** Eigene Freigaben: Personen, Gruppen, Links. */
export interface NcMyShares {
shares: NcShare[];
hidden: number;
truncated: boolean;
}
/** Mit mir geteilt: angenommene und noch offene (`pending`) Freigaben. */
export interface NcReceivedShares extends NcMyShares {
pending: NcShare[];
}
export function getSharePolicy(): Promise<NcSharePolicy> {
@@ -425,3 +448,18 @@ export function updateShare(id: string, input: UpdateShareInput): Promise<NcShar
export function deleteShare(id: string): Promise<{ deleted: true }> {
return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export function listMyShares(): Promise<NcMyShares> {
return request<NcMyShares>('/shares/mine');
}
export function listReceivedShares(): Promise<NcReceivedShares> {
return request<NcReceivedShares>('/shares/received');
}
/** Eine offene Freigabe annehmen; Ablehnen und Verlassen laufen ueber `deleteShare`. */
export function acceptShare(id: string): Promise<{ accepted: true }> {
return request<{ accepted: true }>(`/shares/${encodeURIComponent(id)}/accept`, {
method: 'POST',
});
}
+92 -1
View File
@@ -2344,7 +2344,9 @@
"description": "Ihre Dateien in der Nextcloud ansehen, hochladen, herunterladen und ordnen.",
"tabs": {
"files": "Dateien",
"settings": "Einstellungen"
"settings": "Einstellungen",
"sharedByMe": "Von mir geteilt",
"sharedWithMe": "Mit mir geteilt"
},
"notConfigured": {
"title": "Die Nextcloud ist noch nicht eingerichtet.",
@@ -2603,6 +2605,65 @@
"indicator": {
"outgoing": "Freigaben von „{name}“ öffnen",
"incoming": "Mit Ihnen geteilt"
},
"link": {
"title": "Link",
"empty": "Noch kein Link erstellt.",
"defaultName": "Link",
"create": "Link erstellen",
"createMore": "Weiteren Link erstellen",
"withPassword": "mit Passwort",
"urlLabel": "Adresse des Links",
"copy": "Link kopieren",
"copied": "Kopiert",
"edit": "Ändern",
"editAria": "Link „{name}“ ändern",
"delete": "Löschen",
"deleteAria": "Link „{name}“ löschen",
"deleteConfirm": "Link löschen? Wer ihn hat, kommt danach nicht mehr an „{name}“.",
"deleteYes": "Ja, löschen",
"deleteNo": "Abbrechen",
"passwordNote": "Geben Sie das Passwort getrennt vom Link weiter. Tessera kann es später nicht mehr anzeigen.",
"passwordOfLink": "Passwort des Links",
"copyPassword": "Passwort kopieren",
"passwordCopied": "Kopiert",
"passwordDone": "Verstanden"
},
"linkForm": {
"titleCreate": "Link erstellen",
"titleEdit": "Link ändern",
"accessLegend": "Was dürfen andere tun?",
"accessHint": {
"view": "Andere sehen den Inhalt und können ihn herunterladen.",
"edit": "Andere dürfen im Ordner Dateien ändern, hinzufügen und löschen.",
"editFile": "Andere dürfen die Datei ändern.",
"upload": "Andere legen Dateien in diesen Ordner, sehen aber nichts darin."
},
"protect": "Mit Passwort schützen",
"passwordLabel": "Passwort",
"passwordRequiredLabel": "Passwort (Ihre Nextcloud verlangt eines)",
"generate": "Passwort erzeugen",
"show": "Anzeigen",
"hide": "Verbergen",
"copyPassword": "Passwort kopieren",
"passwordCopied": "Kopiert",
"minLength": "Mindestens {count} Zeichen.",
"hasPassword": "Der Link hat ein Passwort.",
"willRemove": "Das Passwort wird beim Speichern entfernt.",
"change": "Passwort ändern",
"remove": "Passwort entfernen",
"keep": "Passwort behalten",
"expiryLabel": "Ablaufdatum",
"expiryRequiredLabel": "Ablaufdatum (Ihre Nextcloud verlangt eines)",
"expirySet": "Ablaufdatum festlegen",
"expiryNone": "Ohne Ablaufdatum",
"expiryMax": "Spätestens am {date}.",
"expiryOutOfRange": "Dieses Datum lässt Ihre Nextcloud nicht zu.",
"labelLabel": "Bezeichnung (freiwillig)",
"labelHint": "Hilft Ihnen, mehrere Links auseinanderzuhalten.",
"cancel": "Abbrechen",
"submitCreate": "Link erstellen",
"submitSave": "Speichern"
}
},
"codes": {
@@ -2638,6 +2699,36 @@
"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}"
},
"sharesView": {
"loading": "Freigaben werden geladen …",
"byMeEmpty": "Sie haben noch nichts geteilt. Öffnen Sie im Reiter „Dateien“ das Menü einer Datei oder eines Ordners und wählen Sie „Teilen“.",
"withMeEmpty": "Mit Ihnen hat noch niemand etwas geteilt.",
"inFolder": "in {folder}",
"rootFolder": "Hauptordner",
"sharedWith": "Geteilt mit {list}",
"linkRecipient": "Link",
"groupRecipient": "Gruppe {name}",
"from": "von {name}",
"manage": "Freigaben bearbeiten",
"manageAria": "Freigaben von „{name}“ bearbeiten",
"showInFolder": "Im Ordner zeigen",
"showInFolderAria": "„{name}“ im Ordner zeigen",
"open": "Öffnen",
"openAria": "„{name}“ öffnen",
"leave": "Freigabe verlassen",
"leaveAria": "Freigabe „{name}“ verlassen",
"leaveConfirm": "„{name}“ nicht mehr mit Ihnen teilen lassen? Der Eintrag verschwindet danach aus Ihrer Nextcloud.",
"leaveYes": "Ja, verlassen",
"leaveNo": "Abbrechen",
"pendingTitle": "Noch nicht angenommen",
"acceptedTitle": "Angenommen",
"accept": "Annehmen",
"acceptAria": "„{name}“ annehmen",
"decline": "Ablehnen",
"declineAria": "„{name}“ ablehnen",
"truncated": "Es werden nicht alle Freigaben angezeigt. Den Rest sehen Sie in Nextcloud.",
"reload": "Neu laden"
}
}
}
+92 -1
View File
@@ -2344,7 +2344,9 @@
"description": "View, upload, download and organise the files in your Nextcloud.",
"tabs": {
"files": "Files",
"settings": "Settings"
"settings": "Settings",
"sharedByMe": "Shared by me",
"sharedWithMe": "Shared with me"
},
"notConfigured": {
"title": "Nextcloud has not been set up yet.",
@@ -2603,6 +2605,65 @@
"indicator": {
"outgoing": "Open shares of “{name}”",
"incoming": "Shared with you"
},
"link": {
"title": "Link",
"empty": "No link created yet.",
"defaultName": "Link",
"create": "Create link",
"createMore": "Create another link",
"withPassword": "with password",
"urlLabel": "Link address",
"copy": "Copy link",
"copied": "Copied",
"edit": "Change",
"editAria": "Change link “{name}”",
"delete": "Delete",
"deleteAria": "Delete link “{name}”",
"deleteConfirm": "Delete link? Anyone who has it will no longer reach “{name}”.",
"deleteYes": "Yes, delete",
"deleteNo": "Cancel",
"passwordNote": "Pass the password on separately from the link. Tessera cannot show it again later.",
"passwordOfLink": "Password of the link",
"copyPassword": "Copy password",
"passwordCopied": "Copied",
"passwordDone": "Got it"
},
"linkForm": {
"titleCreate": "Create link",
"titleEdit": "Change link",
"accessLegend": "What may others do?",
"accessHint": {
"view": "Others see the content and can download it.",
"edit": "Others may change, add and delete files in the folder.",
"editFile": "Others may change the file.",
"upload": "Others put files into this folder but cannot see anything in it."
},
"protect": "Protect with a password",
"passwordLabel": "Password",
"passwordRequiredLabel": "Password (your Nextcloud requires one)",
"generate": "Generate password",
"show": "Show",
"hide": "Hide",
"copyPassword": "Copy password",
"passwordCopied": "Copied",
"minLength": "At least {count} characters.",
"hasPassword": "The link has a password.",
"willRemove": "The password is removed when you save.",
"change": "Change password",
"remove": "Remove password",
"keep": "Keep password",
"expiryLabel": "Expiry date",
"expiryRequiredLabel": "Expiry date (your Nextcloud requires one)",
"expirySet": "Set an expiry date",
"expiryNone": "No expiry date",
"expiryMax": "At the latest on {date}.",
"expiryOutOfRange": "Your Nextcloud does not allow this date.",
"labelLabel": "Label (optional)",
"labelHint": "Helps you tell several links apart.",
"cancel": "Cancel",
"submitCreate": "Create link",
"submitSave": "Save"
}
},
"codes": {
@@ -2638,6 +2699,36 @@
"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}"
},
"sharesView": {
"loading": "Loading shares …",
"byMeEmpty": "You have not shared anything yet. Open the menu of a file or folder in the “Files” tab and choose “Share”.",
"withMeEmpty": "Nobody has shared anything with you yet.",
"inFolder": "in {folder}",
"rootFolder": "Main folder",
"sharedWith": "Shared with {list}",
"linkRecipient": "Link",
"groupRecipient": "Group {name}",
"from": "from {name}",
"manage": "Manage shares",
"manageAria": "Manage shares of “{name}”",
"showInFolder": "Show in folder",
"showInFolderAria": "Show “{name}” in its folder",
"open": "Open",
"openAria": "Open “{name}”",
"leave": "Leave share",
"leaveAria": "Leave share “{name}”",
"leaveConfirm": "Stop having “{name}” shared with you? The item disappears from your Nextcloud.",
"leaveYes": "Yes, leave",
"leaveNo": "Cancel",
"pendingTitle": "Not accepted yet",
"acceptedTitle": "Accepted",
"accept": "Accept",
"acceptAria": "Accept “{name}”",
"decline": "Decline",
"declineAria": "Decline “{name}”",
"truncated": "Not all shares are shown. You see the rest in Nextcloud.",
"reload": "Reload"
}
}
}
@@ -241,4 +241,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'vergisst',
// quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“
'Suchergebnisse',
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
'verlassen',
];