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
@@ -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" />
+2 -1
View File
@@ -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. */
+6
View File
@@ -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 },
),
},
};
/**
+120
View File
@@ -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 } });
}
+1
View File
@@ -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',
};
/**
+48
View File
@@ -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."
}
}
}
}
+48
View File
@@ -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',
];