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