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:
@@ -8,6 +8,7 @@ import DomaincheckLayout from './domaincheck/layout';
|
||||
import DomainsLayout from './domains/layout';
|
||||
import HandelswareDatevLayout from './handelsware-datev/layout';
|
||||
import KantineDatevLayout from './kantine-datev/layout';
|
||||
import NextcloudFilesLayout from './nextcloud-files/layout';
|
||||
import NextcloudStatusLayout from './nextcloud-status/layout';
|
||||
import TenderRadarLayout from './tender-radar/layout';
|
||||
|
||||
@@ -46,6 +47,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
|
||||
['handelsware-datev', HandelswareDatevLayout],
|
||||
['nextcloud-status', NextcloudStatusLayout],
|
||||
['domains', DomainsLayout],
|
||||
['nextcloud-files', NextcloudFilesLayout],
|
||||
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
|
||||
const element = Layout({ children: placeholderChild });
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,9 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
||||
</>
|
||||
),
|
||||
folder: (
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
),
|
||||
tile: (
|
||||
<>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
|
||||
*/
|
||||
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile';
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'folder' | 'tile';
|
||||
|
||||
const ICONS: Record<string, ModuleIconId> = {
|
||||
'tender-radar': 'radar',
|
||||
@@ -19,6 +19,7 @@ const ICONS: Record<string, ModuleIconId> = {
|
||||
'kantine-datev': 'utensils',
|
||||
'handelsware-datev': 'shopping-bag',
|
||||
domains: 'earth',
|
||||
'nextcloud-files': 'folder',
|
||||
};
|
||||
|
||||
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
|
||||
|
||||
@@ -83,6 +83,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
'nextcloud-files': {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/nextcloud-files/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Nextcloud-Dateien — API-Client (quick-261008-mzu). Konsumiert
|
||||
* `/modules/nextcloud-files/*`. `credentials: 'include'` fuer Cookie-Auth,
|
||||
* `NEXT_PUBLIC_API_URL` als Basis (Muster `nextcloud-status-api.ts`). Der
|
||||
* Browser spricht nie mit der Nextcloud selbst; Zugangsdaten kommen hier nie
|
||||
* zurueck.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const BASE = '/modules/nextcloud-files';
|
||||
|
||||
export interface NextcloudFilesAccount {
|
||||
connected: boolean;
|
||||
expired: boolean;
|
||||
ncUserId: string | null;
|
||||
displayName: string | null;
|
||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
|
||||
}
|
||||
|
||||
export interface NextcloudFilesStatus {
|
||||
configured: boolean;
|
||||
serverUrl: string | null;
|
||||
host: string | null;
|
||||
account: NextcloudFilesAccount | null;
|
||||
}
|
||||
|
||||
export interface NextcloudFilesCheck {
|
||||
ok: boolean;
|
||||
kind: string;
|
||||
message: string;
|
||||
version: string | null;
|
||||
productName: string | null;
|
||||
}
|
||||
|
||||
export interface NextcloudFilesSettings {
|
||||
baseUrl: string | null;
|
||||
connectedAccounts: number;
|
||||
check?: NextcloudFilesCheck;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Status, maschinenlesbarer Kennung, deutscher Servermeldung und Zusatzfeldern. */
|
||||
export class NextcloudFilesRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
message: string,
|
||||
readonly extra: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'NextcloudFilesRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | null = null;
|
||||
let extra: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await res.json();
|
||||
const raw = body?.message;
|
||||
if (Array.isArray(raw)) message = raw.join(' ');
|
||||
else if (typeof raw === 'string') message = raw;
|
||||
if (typeof body?.code === 'string') code = body.code;
|
||||
if (body && typeof body === 'object') {
|
||||
const {
|
||||
code: _code,
|
||||
message: _message,
|
||||
statusCode: _statusCode,
|
||||
error: _error,
|
||||
...rest
|
||||
} = body as Record<string, unknown>;
|
||||
extra = rest;
|
||||
}
|
||||
} catch {
|
||||
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
|
||||
}
|
||||
return new NextcloudFilesRequestError(res.status, code, message, extra);
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const method = init.method ?? 'GET';
|
||||
const headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
if (init.json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(init.json);
|
||||
}
|
||||
const res = await fetch(`${API_URL}${BASE}${path}`, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body,
|
||||
...(method === 'GET' ? { cache: 'no-store' as const } : {}),
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function getNextcloudFilesStatus(): Promise<NextcloudFilesStatus> {
|
||||
return request<NextcloudFilesStatus>('/status');
|
||||
}
|
||||
|
||||
export function getNextcloudFilesSettings(): Promise<NextcloudFilesSettings> {
|
||||
return request<NextcloudFilesSettings>('/settings');
|
||||
}
|
||||
|
||||
export function saveNextcloudFilesSettings(input: {
|
||||
baseUrl: string;
|
||||
confirmReconnect?: boolean;
|
||||
}): Promise<NextcloudFilesSettings> {
|
||||
return request<NextcloudFilesSettings>('/settings', { method: 'PUT', json: input });
|
||||
}
|
||||
|
||||
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
|
||||
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
|
||||
'kantine-datev': 'kantineDatev.title',
|
||||
'handelsware-datev': 'handelswareDatev.title',
|
||||
domains: 'domains.title',
|
||||
'nextcloud-files': 'nextcloudFiles.title',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2320,5 +2320,53 @@
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
|
||||
}
|
||||
},
|
||||
"nextcloudFiles": {
|
||||
"title": "Dateien",
|
||||
"description": "Ihre Dateien in der Nextcloud ansehen, hochladen, herunterladen und ordnen.",
|
||||
"tabs": {
|
||||
"files": "Dateien",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"notConfigured": {
|
||||
"title": "Die Nextcloud ist noch nicht eingerichtet.",
|
||||
"managerHint": "Tragen Sie in den Einstellungen die Adresse Ihrer Nextcloud ein.",
|
||||
"userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten.",
|
||||
"goToSettings": "Zu den Einstellungen"
|
||||
},
|
||||
"main": {
|
||||
"server": "Nextcloud: {host}"
|
||||
},
|
||||
"errors": {
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden."
|
||||
},
|
||||
"settings": {
|
||||
"save": "Speichern",
|
||||
"saving": "Speichere …",
|
||||
"saved": "Gespeichert.",
|
||||
"address": {
|
||||
"title": "Nextcloud-Adresse",
|
||||
"description": "Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, zum Beispiel https://cloud.ihre-firma.de. Interne Adressen sind erlaubt.",
|
||||
"label": "Adresse",
|
||||
"placeholder": "https://cloud.ihre-firma.de",
|
||||
"test": "Verbindung prüfen",
|
||||
"testing": "Prüfe …",
|
||||
"connectedUsers": "{count, plural, =0 {Zurzeit ist niemand verbunden.} one {Ein Benutzer ist verbunden.} other {# Benutzer sind verbunden.}}"
|
||||
},
|
||||
"reconnect": {
|
||||
"message": "{count, plural, one {Ein Benutzer ist verbunden. Nach dem Wechsel muss er sich neu anmelden.} other {# Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.}}",
|
||||
"confirm": "Adresse ändern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"hints": {
|
||||
"title": "Hinweise für die Nextcloud-Administration",
|
||||
"description": "Damit die Anmeldung für alle Benutzer zuverlässig funktioniert.",
|
||||
"whitelist": "Alle Tessera-Benutzer erreichen die Nextcloud von derselben Adresse, der des Tessera-Servers. Mehrere fehlgeschlagene Anmeldungen führen dazu, dass die Nextcloud diese Adresse für alle sperrt. Tragen Sie die Adresse des Tessera-Servers deshalb in die Ausnahmeliste der Brute-Force-Erkennung ein:",
|
||||
"https": "Verwenden Sie nach Möglichkeit https. Zertifikate werden geprüft; eine eigene Zertifizierungsstelle muss auf dem Tessera-Server hinterlegt sein.",
|
||||
"trustedDomains": "Der Rechnername der Adresse muss in den vertrauenswürdigen Domains (trusted_domains) der Nextcloud stehen.",
|
||||
"twoFactor": "Benutzer mit Zwei-Faktor-Anmeldung verbinden sich über den Browser, die Anmeldung mit Passwort genügt für sie nicht."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2320,5 +2320,53 @@
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the AutoDNS connection could not be loaded."
|
||||
}
|
||||
},
|
||||
"nextcloudFiles": {
|
||||
"title": "Files",
|
||||
"description": "View, upload, download and organise the files in your Nextcloud.",
|
||||
"tabs": {
|
||||
"files": "Files",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"notConfigured": {
|
||||
"title": "Nextcloud has not been set up yet.",
|
||||
"managerHint": "Enter the address of your Nextcloud in the settings.",
|
||||
"userHint": "Please contact an administrator or someone with the Manage permission level.",
|
||||
"goToSettings": "Go to settings"
|
||||
},
|
||||
"main": {
|
||||
"server": "Nextcloud: {host}"
|
||||
},
|
||||
"errors": {
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the Nextcloud connection could not be loaded."
|
||||
},
|
||||
"settings": {
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"saved": "Saved.",
|
||||
"address": {
|
||||
"title": "Nextcloud address",
|
||||
"description": "Enter the address the way your users open Nextcloud in the browser, for example https://cloud.your-company.com. Internal addresses are allowed.",
|
||||
"label": "Address",
|
||||
"placeholder": "https://cloud.your-company.com",
|
||||
"test": "Check connection",
|
||||
"testing": "Checking …",
|
||||
"connectedUsers": "{count, plural, =0 {Nobody is connected right now.} one {One user is connected.} other {# users are connected.}}"
|
||||
},
|
||||
"reconnect": {
|
||||
"message": "{count, plural, one {One user is connected. After the change they have to sign in again.} other {# users are connected. After the change everyone has to sign in again.}}",
|
||||
"confirm": "Change address",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"hints": {
|
||||
"title": "Notes for the Nextcloud administration",
|
||||
"description": "So that signing in works reliably for everyone.",
|
||||
"whitelist": "All Tessera users reach Nextcloud from the same address, that of the Tessera server. Several failed sign-ins make Nextcloud block this address for everyone. Add the address of the Tessera server to the allow list of the brute-force protection:",
|
||||
"https": "Use https where possible. Certificates are verified; a private certificate authority has to be installed on the Tessera server.",
|
||||
"trustedDomains": "The host name of the address has to be listed in the trusted domains (trusted_domains) of Nextcloud.",
|
||||
"twoFactor": "Users with two-factor authentication connect through the browser; signing in with a password is not enough for them."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,4 +234,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Zusammenfassung',
|
||||
'passender',
|
||||
'dass',
|
||||
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
|
||||
'zuverlässig',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user