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,
|
minSearchLength: 0,
|
||||||
passwordMinLength: 10,
|
passwordMinLength: 10,
|
||||||
});
|
});
|
||||||
mockSharesForPath
|
mockSharesForPath.mockReset().mockImplementation(async (p: string) => ({
|
||||||
.mockReset()
|
path: p,
|
||||||
.mockImplementation(async (p: string) => ({
|
shares: [],
|
||||||
path: p,
|
hidden: 0,
|
||||||
shares: [],
|
truncated: false,
|
||||||
hidden: 0,
|
}));
|
||||||
truncated: false,
|
|
||||||
}));
|
|
||||||
onExpired = vi.fn();
|
onExpired = vi.fn();
|
||||||
clicked = [];
|
clicked = [];
|
||||||
submitted = [];
|
submitted = [];
|
||||||
@@ -229,6 +227,30 @@ describe('FileBrowser — Liste', () => {
|
|||||||
expect(window.location.search).toBe('');
|
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 () => {
|
it('Enter oeffnet einen Ordner und laedt eine Datei herunter', async () => {
|
||||||
await mount();
|
await mount();
|
||||||
const file = screen.getByRole('row', { name: 'Bericht.pdf' });
|
const file = screen.getByRole('row', { name: 'Bericht.pdf' });
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export function FileBrowser({
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
onExpired,
|
onExpired,
|
||||||
sharingEnabled = true,
|
sharingEnabled = true,
|
||||||
|
initialPath,
|
||||||
|
initialFocus,
|
||||||
}: {
|
}: {
|
||||||
/** Adresse der Nextcloud fuer "In Nextcloud öffnen". */
|
/** Adresse der Nextcloud fuer "In Nextcloud öffnen". */
|
||||||
serverUrl: string | null;
|
serverUrl: string | null;
|
||||||
@@ -155,6 +157,10 @@ export function FileBrowser({
|
|||||||
onExpired: () => void;
|
onExpired: () => void;
|
||||||
/** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */
|
/** Teilen anbieten (Menueeintrag, Kennzeichen als Knopf). Die Nextcloud kann es ausschalten. */
|
||||||
sharingEnabled?: boolean;
|
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 t = useTranslations('nextcloudFiles.browser');
|
||||||
const tCodes = useTranslations('nextcloudFiles.codes');
|
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).
|
// 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(() => {
|
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 (raw?.startsWith('/') && !raw.includes('\0')) setPath(raw);
|
||||||
|
if (initialPath && initialFocus) {
|
||||||
|
focusAfterLoad.current = `${initialPath.replace(/\/+$/, '')}/${initialFocus}`;
|
||||||
|
}
|
||||||
setReady(true);
|
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 { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 {
|
import {
|
||||||
type NcShare,
|
type NcShare,
|
||||||
type NcSharee,
|
type NcSharee,
|
||||||
@@ -376,3 +376,330 @@ describe('ShareDialog — Personen und Gruppen', () => {
|
|||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
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,
|
ncMessageOf,
|
||||||
toErrorLike,
|
toErrorLike,
|
||||||
} from '@/components/nextcloud-files/error-text';
|
} from '@/components/nextcloud-files/error-text';
|
||||||
|
import { copyText } from '@/components/nextcloud-files/clipboard';
|
||||||
import {
|
import {
|
||||||
accessOptions,
|
accessOptions,
|
||||||
|
type LinkFormValues,
|
||||||
|
linkUpdateDiff,
|
||||||
type ShareAccessChoice,
|
type ShareAccessChoice,
|
||||||
type ShareTarget,
|
type ShareTarget,
|
||||||
} from '@/components/nextcloud-files/share-policy';
|
} from '@/components/nextcloud-files/share-policy';
|
||||||
@@ -25,7 +28,8 @@ import {
|
|||||||
updateShare,
|
updateShare,
|
||||||
} from '@/lib/nextcloud-files-api';
|
} from '@/lib/nextcloud-files-api';
|
||||||
import { Dialog } from './Dialog';
|
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 SEARCH_DEBOUNCE_MS = 300;
|
||||||
const SELECT_CLASS =
|
const SELECT_CLASS =
|
||||||
@@ -37,9 +41,13 @@ function dateText(date: string, locale: string): string {
|
|||||||
return Number.isNaN(d.getTime()) ? date : d.toLocaleDateString(locale);
|
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,
|
* 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
|
* 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
|
* 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.
|
* 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 [hidden, setHidden] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<ErrorLike | null>(null);
|
const [error, setError] = useState<ErrorLike | null>(null);
|
||||||
|
const [errorSection, setErrorSection] = useState<Section>('people');
|
||||||
const [busy, setBusy] = useState(false);
|
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 [term, setTerm] = useState('');
|
||||||
const [results, setResults] = useState<NcSharee[]>([]);
|
const [results, setResults] = useState<NcSharee[]>([]);
|
||||||
const [searchedFor, setSearchedFor] = useState<string | null>(null);
|
const [searchedFor, setSearchedFor] = useState<string | null>(null);
|
||||||
@@ -164,14 +181,16 @@ export function ShareDialog({
|
|||||||
[shares],
|
[shares],
|
||||||
);
|
);
|
||||||
const people = shares.filter((s) => s.kind !== 'link');
|
const people = shares.filter((s) => s.kind !== 'link');
|
||||||
|
const links = shares.filter((s) => s.kind === 'link');
|
||||||
const options = policy
|
const options = policy
|
||||||
? accessOptions(target, 'user', policy)
|
? accessOptions(target, 'user', policy)
|
||||||
: (['view'] as ShareAccessChoice[]);
|
: (['view'] as ShareAccessChoice[]);
|
||||||
|
|
||||||
const run = async (action: () => Promise<void>) => {
|
const run = async (section: Section, action: () => Promise<void>) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setErrorSection(section);
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -183,7 +202,7 @@ export function ShareDialog({
|
|||||||
|
|
||||||
const choose = (s: NcSharee) => {
|
const choose = (s: NcSharee) => {
|
||||||
if (sharedKeys.has(`${s.kind}:${s.id}`)) return;
|
if (sharedKeys.has(`${s.kind}:${s.id}`)) return;
|
||||||
void run(async () => {
|
void run('people', async () => {
|
||||||
const created = await createShare({
|
const created = await createShare({
|
||||||
path: target.path,
|
path: target.path,
|
||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
@@ -200,19 +219,75 @@ export function ShareDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const changeAccess = (share: NcShare, access: ShareAccessChoice) =>
|
const changeAccess = (share: NcShare, access: ShareAccessChoice) =>
|
||||||
run(async () => {
|
run('people', async () => {
|
||||||
const updated = await updateShare(share.id, { access });
|
const updated = await updateShare(share.id, { access });
|
||||||
setShares((prev) => prev.map((s) => (s.id === share.id ? updated : s)));
|
setShares((prev) => prev.map((s) => (s.id === share.id ? updated : s)));
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = (share: NcShare) =>
|
const remove = (share: NcShare) =>
|
||||||
run(async () => {
|
run('people', async () => {
|
||||||
await deleteShare(share.id);
|
await deleteShare(share.id);
|
||||||
setShares((prev) => prev.filter((s) => s.id !== share.id));
|
setShares((prev) => prev.filter((s) => s.id !== share.id));
|
||||||
onChanged();
|
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>) => {
|
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -240,6 +315,14 @@ export function ShareDialog({
|
|||||||
const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0;
|
const noResults = !searching && searchedFor === trimmed && trimmed !== '' && results.length === 0;
|
||||||
const detail = error ? ncMessageOf(error) : null;
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
wide
|
wide
|
||||||
@@ -435,18 +518,210 @@ export function ShareDialog({
|
|||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{t('hidden', { count: hidden })}</p>
|
<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>
|
</section>
|
||||||
</div>
|
</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 && (
|
{error && !enabled && !loading && detail && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{tCodes('ncDetail', { detail })}</p>
|
<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 mockDisconnect = vi.fn();
|
||||||
const mockListFolder = vi.fn();
|
const mockListFolder = vi.fn();
|
||||||
const mockGetServerInfo = 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) => {
|
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
|
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),
|
disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args),
|
||||||
listFolder: (...args: unknown[]) => mockListFolder(...args),
|
listFolder: (...args: unknown[]) => mockListFolder(...args),
|
||||||
getServerInfo: (...args: unknown[]) => mockGetServerInfo(...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 });
|
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
|
||||||
mockDisconnect.mockReset();
|
mockDisconnect.mockReset();
|
||||||
mockGetServerInfo.mockReset().mockResolvedValue(serverInfo());
|
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({
|
mockListFolder.mockReset().mockResolvedValue({
|
||||||
path: '/',
|
path: '/',
|
||||||
entries: [
|
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)', () => {
|
describe('Kennung der Nextcloud (Aufgabe 6)', () => {
|
||||||
it('Anmeldebildschirm zeigt Name und Rechnername, die Kachel in der Themenfarbe, ohne Logo den Anfangsbuchstaben', async () => {
|
it('Anmeldebildschirm zeigt Name und Rechnername, die Kachel in der Themenfarbe, ohne Logo den Anfangsbuchstaben', async () => {
|
||||||
render(<NextcloudFilesPage />);
|
render(<NextcloudFilesPage />);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { PageHeader } from '@/components/layout/page-header';
|
|||||||
import {
|
import {
|
||||||
getNextcloudFilesStatus,
|
getNextcloudFilesStatus,
|
||||||
getServerInfo,
|
getServerInfo,
|
||||||
|
getSharePolicy,
|
||||||
|
type NcSharePolicy,
|
||||||
type NextcloudFilesStatus,
|
type NextcloudFilesStatus,
|
||||||
type NextcloudServerInfo,
|
type NextcloudServerInfo,
|
||||||
} from '@/lib/nextcloud-files-api';
|
} from '@/lib/nextcloud-files-api';
|
||||||
@@ -17,11 +19,20 @@ import { AccountBar } from './components/AccountBar';
|
|||||||
import { ConnectPanel } from './components/ConnectPanel';
|
import { ConnectPanel } from './components/ConnectPanel';
|
||||||
import { FileBrowser } from './components/FileBrowser';
|
import { FileBrowser } from './components/FileBrowser';
|
||||||
import { SettingsTab } from './components/SettingsTab';
|
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.
|
* 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
|
* Den Reiter "Einstellungen" sehen Administratoren und Benutzer mit der
|
||||||
* Freigabestufe Verwalten; die Adresse der Nextcloud AENDERN darf nur ein
|
* Freigabestufe Verwalten; die Adresse der Nextcloud AENDERN darf nur ein
|
||||||
* Administrator (CR-02). Alle anderen sehen nur den Reiter "Dateien" ohne
|
* 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 [statusError, setStatusError] = useState(false);
|
||||||
const [tab, setTab] = useState<TabId>('files');
|
const [tab, setTab] = useState<TabId>('files');
|
||||||
const [serverInfo, setServerInfo] = useState<NextcloudServerInfo | null>(null);
|
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 () => {
|
const reloadStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,10 +94,49 @@ export default function NextcloudFilesPage() {
|
|||||||
const account = status?.account ?? null;
|
const account = status?.account ?? null;
|
||||||
const connected = account?.connected === true;
|
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') }];
|
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') });
|
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||||
const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files';
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -92,7 +144,7 @@ export default function NextcloudFilesPage() {
|
|||||||
title={t('title')}
|
title={t('title')}
|
||||||
description={t('description')}
|
description={t('description')}
|
||||||
actions={
|
actions={
|
||||||
activeTab === 'files' && status?.configured && account && connected ? (
|
activeTab !== 'settings' && status?.configured && account && connected ? (
|
||||||
<AccountBar
|
<AccountBar
|
||||||
account={account}
|
account={account}
|
||||||
host={status.host}
|
host={status.host}
|
||||||
@@ -107,7 +159,7 @@ export default function NextcloudFilesPage() {
|
|||||||
{t('errors.loadStatus')}
|
{t('errors.loadStatus')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{canManage && <TabBar tabs={tabs} active={activeTab} onChange={setTab} />}
|
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} onChange={pickTab} />}
|
||||||
{activeTab === 'files' && status && !status.configured && (
|
{activeTab === 'files' && status && !status.configured && (
|
||||||
<SettingsSection title={t('notConfigured.title')}>
|
<SettingsSection title={t('notConfigured.title')}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -116,7 +168,7 @@ export default function NextcloudFilesPage() {
|
|||||||
{isAdmin && canManage && (
|
{isAdmin && canManage && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
>
|
>
|
||||||
{t('notConfigured.goToSettings')}
|
{t('notConfigured.goToSettings')}
|
||||||
@@ -128,7 +180,13 @@ export default function NextcloudFilesPage() {
|
|||||||
<section data-testid="nextcloud-files-main" className="space-y-4">
|
<section data-testid="nextcloud-files-main" className="space-y-4">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<div data-testid="nextcloud-files-browser">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ConnectPanel
|
<ConnectPanel
|
||||||
@@ -140,6 +198,16 @@ export default function NextcloudFilesPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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 && (
|
{activeTab === 'settings' && canManage && (
|
||||||
<SettingsTab onChanged={() => void reloadStatus()} />
|
<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 { describe, expect, it } from 'vitest';
|
||||||
import type { NcSharePolicy } from '@/lib/nextcloud-files-api';
|
import type { NcShare, NcSharePolicy } from '@/lib/nextcloud-files-api';
|
||||||
import {
|
import {
|
||||||
accessOptions,
|
accessOptions,
|
||||||
|
addDays,
|
||||||
|
expiryRule,
|
||||||
generatePassword,
|
generatePassword,
|
||||||
|
linkPasswordMode,
|
||||||
|
linkUpdateDiff,
|
||||||
type RandomFill,
|
type RandomFill,
|
||||||
type ShareTarget,
|
type ShareTarget,
|
||||||
targetFromEntry,
|
targetFromEntry,
|
||||||
|
todayLocal,
|
||||||
} from './share-policy';
|
} from './share-policy';
|
||||||
|
|
||||||
const POLICY: NcSharePolicy = {
|
const POLICY: NcSharePolicy = {
|
||||||
@@ -65,6 +70,8 @@ describe('accessOptions', () => {
|
|||||||
const noUpload = { ...POLICY, links: { ...POLICY.links, uploadAllowed: false } };
|
const noUpload = { ...POLICY, links: { ...POLICY.links, uploadAllowed: false } };
|
||||||
expect(accessOptions(folder(true), 'link', noUpload)).toEqual(['view']);
|
expect(accessOptions(folder(true), 'link', noUpload)).toEqual(['view']);
|
||||||
expect(accessOptions(folder(true), 'link', null)).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));
|
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
|
* Reine Hilfen fuer das Teilen (quick-261009-dkv): was der Dialog anbietet, wird hier aus dem
|
||||||
@@ -44,8 +44,8 @@ export function accessOptions(
|
|||||||
): ShareAccessChoice[] {
|
): ShareAccessChoice[] {
|
||||||
const options: ShareAccessChoice[] = ['view'];
|
const options: ShareAccessChoice[] = ['view'];
|
||||||
if (audience === 'link') {
|
if (audience === 'link') {
|
||||||
if (!policy?.links.uploadAllowed) return options;
|
if (!policy?.links.uploadAllowed || !target.writable) return options;
|
||||||
if (target.writable) options.push('edit');
|
options.push('edit');
|
||||||
if (target.type === 'folder') options.push('upload');
|
if (target.type === 'folder') options.push('upload');
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,72 @@ export function accessOptions(
|
|||||||
return options;
|
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 --------------------------------------------------------------------------
|
// --- Passwort erzeugen --------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Ohne Zeichen, die sich ähneln (0 O o, 1 l I). */
|
/** Ohne Zeichen, die sich ähneln (0 O o, 1 l I). */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
acceptShare,
|
||||||
checkDownload,
|
checkDownload,
|
||||||
checkZip,
|
checkZip,
|
||||||
createFolder,
|
createFolder,
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
downloadUrl,
|
downloadUrl,
|
||||||
getSharePolicy,
|
getSharePolicy,
|
||||||
listFolder,
|
listFolder,
|
||||||
|
listMyShares,
|
||||||
|
listReceivedShares,
|
||||||
listSharesForPath,
|
listSharesForPath,
|
||||||
moveEntry,
|
moveEntry,
|
||||||
NextcloudFilesRequestError,
|
NextcloudFilesRequestError,
|
||||||
@@ -226,4 +229,43 @@ describe('nextcloud-files-api — Teilen', () => {
|
|||||||
expect(err.code).toBe('shareRejected');
|
expect(err.code).toBe('shareRejected');
|
||||||
expect(err.extra).toEqual({ ncMessage: 'Nein' });
|
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;
|
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 {
|
export interface CreateShareInput {
|
||||||
path: string;
|
path: string;
|
||||||
kind: 'user' | 'group';
|
kind: NcShareKind;
|
||||||
shareWith: string;
|
shareWith?: string;
|
||||||
access: 'view' | 'edit' | 'upload';
|
access: 'view' | 'edit' | 'upload';
|
||||||
|
password?: string;
|
||||||
|
expireDate?: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leere Zeichenkette heisst: Passwort entfernen, Ablaufdatum entfernen, Bezeichnung leeren. */
|
||||||
export interface UpdateShareInput {
|
export interface UpdateShareInput {
|
||||||
access?: 'view' | 'edit' | 'upload';
|
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> {
|
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 }> {
|
export function deleteShare(id: string): Promise<{ deleted: true }> {
|
||||||
return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
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.",
|
"description": "Ihre Dateien in der Nextcloud ansehen, hochladen, herunterladen und ordnen.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"sharedByMe": "Von mir geteilt",
|
||||||
|
"sharedWithMe": "Mit mir geteilt"
|
||||||
},
|
},
|
||||||
"notConfigured": {
|
"notConfigured": {
|
||||||
"title": "Die Nextcloud ist noch nicht eingerichtet.",
|
"title": "Die Nextcloud ist noch nicht eingerichtet.",
|
||||||
@@ -2603,6 +2605,65 @@
|
|||||||
"indicator": {
|
"indicator": {
|
||||||
"outgoing": "Freigaben von „{name}“ öffnen",
|
"outgoing": "Freigaben von „{name}“ öffnen",
|
||||||
"incoming": "Mit Ihnen geteilt"
|
"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": {
|
"codes": {
|
||||||
@@ -2638,6 +2699,36 @@
|
|||||||
"shareNotFound": "Diese Freigabe gibt es nicht mehr.",
|
"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}}.",
|
"tooManyShares": "Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.",
|
||||||
"ncDetail": "Meldung der Nextcloud: {detail}"
|
"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.",
|
"description": "View, upload, download and organise the files in your Nextcloud.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"sharedByMe": "Shared by me",
|
||||||
|
"sharedWithMe": "Shared with me"
|
||||||
},
|
},
|
||||||
"notConfigured": {
|
"notConfigured": {
|
||||||
"title": "Nextcloud has not been set up yet.",
|
"title": "Nextcloud has not been set up yet.",
|
||||||
@@ -2603,6 +2605,65 @@
|
|||||||
"indicator": {
|
"indicator": {
|
||||||
"outgoing": "Open shares of “{name}”",
|
"outgoing": "Open shares of “{name}”",
|
||||||
"incoming": "Shared with you"
|
"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": {
|
"codes": {
|
||||||
@@ -2638,6 +2699,36 @@
|
|||||||
"shareNotFound": "This share no longer exists.",
|
"shareNotFound": "This share no longer exists.",
|
||||||
"tooManyShares": "You have created many shares in a short time. Please wait {minutes, plural, one {# minute} other {# minutes}}.",
|
"tooManyShares": "You have created many shares in a short time. Please wait {minutes, plural, one {# minute} other {# minutes}}.",
|
||||||
"ncDetail": "Message from Nextcloud: {detail}"
|
"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',
|
'vergisst',
|
||||||
// quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“
|
// quick-261009-dkv: Teilen — korrektes Deutsch mit „ss“
|
||||||
'Suchergebnisse',
|
'Suchergebnisse',
|
||||||
|
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
|
||||||
|
'verlassen',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user