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:
@@ -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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user