feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht

- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount
  (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort)
- Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen,
  keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und
  sperrt Zugangsschlüssel nach dem ersten 401
- Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung)
- Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web
  (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen
- RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
@@ -0,0 +1,238 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
getNextcloudFilesSettings,
type NextcloudFilesCheck,
NextcloudFilesRequestError,
type NextcloudFilesSettings,
saveNextcloudFilesSettings,
testNextcloudFilesSettings,
} from '@/lib/nextcloud-files-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const PRIMARY_BUTTON =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON =
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
/** Befehl fuer die Ausnahmeliste der Brute-Force-Erkennung (sprachneutral, kein Uebersetzungstext). */
const WHITELIST_COMMAND =
'occ config:app:set bruteForce whitelist_0 --value=<IP-Adresse des Tessera-Servers>';
type Message = { kind: 'ok' | 'error'; text: string };
function MessageLine({ message }: { message: Message | null }) {
if (!message) return null;
return (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
);
}
/**
* Einstellungen des Moduls Dateien (quick-261008-mzu): die EINE Nextcloud-Adresse
* der Organisation, die Verbindungspruefung und Hinweise fuer die
* Nextcloud-Administration. Nur fuer Verwalter sichtbar (die Seite blendet den
* Reiter sonst aus; bindend ist die API).
*/
export function SettingsTab({ onChanged }: { onChanged: () => void }) {
const t = useTranslations('nextcloudFiles.settings');
const tErrors = useTranslations('nextcloudFiles.errors');
const [settings, setSettings] = useState<NextcloudFilesSettings | null>(null);
const [address, setAddress] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmCount, setConfirmCount] = useState<number | null>(null);
const [message, setMessage] = useState<Message | null>(null);
const errorText = (error: unknown) =>
error instanceof NextcloudFilesRequestError ? error.message : tErrors('request');
useEffect(() => {
let cancelled = false;
getNextcloudFilesSettings()
.then((loaded) => {
if (cancelled) return;
setSettings(loaded);
setAddress(loaded.baseUrl ?? '');
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, []);
const showCheck = (check: NextcloudFilesCheck) =>
setMessage({ kind: check.ok ? 'ok' : 'error', text: check.message });
const runTest = async () => {
setTesting(true);
setMessage(null);
try {
showCheck(await testNextcloudFilesSettings(address.trim()));
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setTesting(false);
}
};
const save = async (confirmReconnect: boolean) => {
setSaving(true);
setMessage(null);
try {
const saved = await saveNextcloudFilesSettings({
baseUrl: address.trim(),
...(confirmReconnect ? { confirmReconnect: true } : {}),
});
setSettings(saved);
setAddress(saved.baseUrl ?? address.trim());
setConfirmCount(null);
if (saved.check) showCheck(saved.check);
else setMessage({ kind: 'ok', text: t('saved') });
onChanged();
} catch (error) {
if (error instanceof NextcloudFilesRequestError && error.code === 'confirmReconnect') {
const count = Number(error.extra.connectedAccounts);
setConfirmCount(Number.isFinite(count) ? count : (settings?.connectedAccounts ?? 0));
} else {
setMessage({ kind: 'error', text: errorText(error) });
}
} finally {
setSaving(false);
}
};
const onSaveClick = () => {
const changed = address.trim() !== (settings?.baseUrl ?? '');
const connected = settings?.connectedAccounts ?? 0;
if (changed && settings?.baseUrl && connected > 0) {
// Vor dem Wechsel ausdruecklich bestaetigen lassen: alle verbundenen Benutzer muessen sich neu anmelden.
setMessage(null);
setConfirmCount(connected);
return;
}
void save(false);
};
const busy = testing || saving;
const empty = address.trim() === '';
return (
<div className="space-y-6">
<SettingsSection
id="nextcloud-files-address"
title={t('address.title')}
description={t('address.description')}
footer={
confirmCount === null ? (
<>
<MessageLine message={message} />
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy || empty}
onClick={() => void runTest()}
>
{testing ? t('address.testing') : t('address.test')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || empty || loadFailed}
onClick={onSaveClick}
>
{saving ? t('saving') : t('save')}
</button>
</>
) : (
<>
<p role="alert" className="mr-auto text-sm text-foreground">
{t('reconnect.message', { count: confirmCount })}
</p>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={saving}
onClick={() => setConfirmCount(null)}
>
{t('reconnect.cancel')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving}
onClick={() => void save(true)}
>
{t('reconnect.confirm')}
</button>
</>
)
}
>
{loadFailed ? (
<p role="alert" className="text-sm text-destructive">
{tErrors('request')}
</p>
) : (
<div className="space-y-2">
<label
htmlFor="nextcloud-files-address-input"
className="block text-sm font-medium text-foreground"
>
{t('address.label')}
</label>
<input
id="nextcloud-files-address-input"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
className={INPUT_CLASS}
value={address}
placeholder={t('address.placeholder')}
onChange={(event) => {
setAddress(event.target.value);
setMessage(null);
setConfirmCount(null);
}}
/>
{settings?.baseUrl && (
<p className="text-xs text-muted-foreground">
{t('address.connectedUsers', { count: settings.connectedAccounts })}
</p>
)}
</div>
)}
</SettingsSection>
<SettingsSection
id="nextcloud-files-hints"
title={t('hints.title')}
description={t('hints.description')}
>
<div className="space-y-3 text-sm text-foreground">
<p>{t('hints.whitelist')}</p>
<pre className="overflow-x-auto rounded-md bg-well px-3 py-2 text-xs">
<code>{WHITELIST_COMMAND}</code>
</pre>
<p>{t('hints.https')}</p>
<p>{t('hints.trustedDomains')}</p>
<p>{t('hints.twoFactor')}</p>
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function NextcloudFilesLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="nextcloud-files">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,198 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudFilesSettings, NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
import de from '@/messages/de.json';
import NextcloudFilesPage from './page';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestSettings = vi.fn();
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
return {
...actual,
getNextcloudFilesStatus: (...args: unknown[]) => mockGetStatus(...args),
getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args),
saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args),
testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...args),
};
});
let mockCanManage: boolean | null = true;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
function status(over: Partial<NextcloudFilesStatus> = {}): NextcloudFilesStatus {
return {
configured: true,
serverUrl: 'https://cloud.example',
host: 'cloud.example',
account: null,
...over,
};
}
function settings(over: Partial<NextcloudFilesSettings> = {}): NextcloudFilesSettings {
return { baseUrl: 'https://cloud.example', connectedAccounts: 0, ...over };
}
beforeEach(() => {
mockCanManage = true;
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset();
mockTestSettings.mockReset();
});
afterEach(() => cleanup());
describe('NextcloudFilesPage', () => {
it('nicht eingerichtet, Verwalter: Hinweis mit Knopf zu den Einstellungen', async () => {
mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null }));
render(<NextcloudFilesPage />);
expect(await screen.findByText('Die Nextcloud ist noch nicht eingerichtet.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
expect(await screen.findByLabelText('Adresse')).toBeTruthy();
});
it('nicht eingerichtet, Benutzen: Hinweis auf einen Administrator, kein Reiter, kein Einstellungsknopf', async () => {
mockCanManage = false;
mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null }));
render(<NextcloudFilesPage />);
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
expect(screen.queryByRole('navigation')).toBeNull();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull();
expect(mockGetSettings).not.toHaveBeenCalled();
});
it('Verwalter sehen die Reiter Dateien und Einstellungen', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByRole('button', { name: 'Dateien' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy();
});
it('eingerichtet, Benutzen: Hauptbereich ohne Reiterleiste', async () => {
mockCanManage = false;
render(<NextcloudFilesPage />);
expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy();
expect(screen.queryByRole('navigation')).toBeNull();
});
});
describe('SettingsTab', () => {
async function openSettings() {
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
return screen.findByLabelText('Adresse');
}
it('"Verbindung prüfen" ruft die Pruefung genau einmal je Klick auf, ist waehrenddessen gesperrt und zeigt die Meldung', async () => {
let resolve!: (v: unknown) => void;
mockTestSettings.mockReturnValue(new Promise((r) => (resolve = r)));
const input = await openSettings();
fireEvent.change(input, { target: { value: ' https://cloud.example ' } });
const button = screen.getByRole('button', { name: 'Verbindung prüfen' });
fireEvent.click(button);
expect(mockTestSettings).toHaveBeenCalledTimes(1);
expect(mockTestSettings).toHaveBeenCalledWith('https://cloud.example');
const running = await screen.findByRole('button', { name: 'Prüfe …' });
expect((running as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(running);
expect(mockTestSettings).toHaveBeenCalledTimes(1);
resolve({
ok: true,
kind: 'ok',
message: 'Verbindung erfolgreich: Nextcloud 34.0.4.',
version: '34.0.4',
productName: 'Nextcloud',
});
expect(await screen.findByText('Verbindung erfolgreich: Nextcloud 34.0.4.')).toBeTruthy();
});
it('eine fehlgeschlagene Pruefung zeigt den Text der API als Fehler', async () => {
mockTestSettings.mockResolvedValue({
ok: false,
kind: 'timeout',
message: 'Nextcloud hat nicht rechtzeitig geantwortet.',
version: null,
productName: null,
});
await openSettings();
fireEvent.click(screen.getByRole('button', { name: 'Verbindung prüfen' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('nicht rechtzeitig');
});
it('Aenderung der Adresse mit verbundenen Benutzern: erst Bestaetigung mit Anzahl, nur die bestaetigte Speicherung sendet confirmReconnect', async () => {
mockGetSettings.mockResolvedValue(settings({ connectedAccounts: 2 }));
mockSaveSettings.mockResolvedValue(settings({ baseUrl: 'https://neu.example' }));
const input = await openSettings();
await screen.findByText('2 Benutzer sind verbunden.');
fireEvent.change(input, { target: { value: 'https://neu.example' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(mockSaveSettings).not.toHaveBeenCalled();
expect(
await screen.findByText(
'2 Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Adresse ändern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({
baseUrl: 'https://neu.example',
confirmReconnect: true,
});
});
it('Abbrechen in der Bestaetigung speichert nichts', async () => {
mockGetSettings.mockResolvedValue(settings({ connectedAccounts: 1 }));
const input = await openSettings();
await screen.findByText('Ein Benutzer ist verbunden.');
fireEvent.change(input, { target: { value: 'https://neu.example' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
fireEvent.click(await screen.findByRole('button', { name: 'Abbrechen' }));
expect(mockSaveSettings).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Speichern' })).toBeTruthy();
});
it('ohne verbundene Benutzer wird sofort ohne confirmReconnect gespeichert', async () => {
mockSaveSettings.mockResolvedValue(
settings({
baseUrl: 'https://neu.example',
check: {
ok: true,
kind: 'ok',
message: 'Verbindung erfolgreich.',
version: '34.0.4',
productName: 'Nextcloud',
},
}),
);
const input = await openSettings();
fireEvent.change(input, { target: { value: 'https://neu.example' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({ baseUrl: 'https://neu.example' });
expect(await screen.findByText('Verbindung erfolgreich.')).toBeTruthy();
});
it('die Hinweiskarte nennt den Befehl fuer die Ausnahmeliste', async () => {
await openSettings();
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
});
});
@@ -0,0 +1,84 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { SettingsSection } from '@/components/control-center/settings-section';
import { PageHeader } from '@/components/layout/page-header';
import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'files' | 'settings';
/**
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Der
* Abschnitt `nextcloud-files-main` ist die Stelle, an der das Verbinden des
* eigenen Kontos und der Dateibrowser eingehaengt werden.
*/
export default function NextcloudFilesPage() {
const t = useTranslations('nextcloudFiles');
const canManage = useCanManageModule('nextcloud-files') === true;
const [status, setStatus] = useState<NextcloudFilesStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('files');
const reloadStatus = useCallback(async () => {
try {
setStatus(await getNextcloudFilesStatus());
setStatusError(false);
} catch {
setStatusError(true);
}
}, []);
useEffect(() => {
void reloadStatus();
}, [reloadStatus]);
const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files';
return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-files" title={t('title')} description={t('description')} />
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
</p>
)}
{canManage && <TabBar tabs={tabs} active={activeTab} onChange={setTab} />}
{activeTab === 'files' && status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p>
{canManage && (
<button
type="button"
onClick={() => setTab('settings')}
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{t('notConfigured.goToSettings')}
</button>
)}
</SettingsSection>
)}
{activeTab === 'files' && status?.configured && (
<section data-testid="nextcloud-files-main" className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('main.server', { host: status.host ?? '' })}
</p>
</section>
)}
{activeTab === 'settings' && canManage && (
<SettingsTab onChanged={() => void reloadStatus()} />
)}
</div>
);
}