feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf

- Anmelde-Client (getapppassword, cloud/user, Widerruf, Login Flow v2 mit fester Abfrageadresse,
  Link aus Basis und Token neu gebaut), Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server,
  Ablaufspeicher für Browser-Anmeldungen (20 min, höchstens 200, eine je Benutzer)
- Kontodienst: Verbinden, Trennen mit Widerruf, Sitzung mit Zugangsschlüssel-Sperre,
  frisch ausgestellte oder ersetzte App-Passwörter bleiben nie verwaist; jeder Kontozugriff
  über forTenant mit Mandant UND Benutzer aus dem Token
- Migration 20261008183000: Spalte ncLoginName (App-Passwort gilt nur für den Anmeldenamen der
  Ausstellung, gemessen mit E-Mail-Anmeldung gegen Nextcloud 34)
- Verbindungsbildschirm und Kontoleiste, Texte de/en, RLS-Inventar fortgeschrieben,
  E2E-Skript e2e-connect.sh

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:02:45 +02:00
parent 960696745f
commit d00b6ff79f
25 changed files with 3533 additions and 55 deletions
@@ -0,0 +1,98 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
disconnectNextcloud,
type NextcloudFilesAccount,
NextcloudFilesRequestError,
} from '@/lib/nextcloud-files-api';
/**
* Kontoleiste im Seitenkopf (quick-261008-mzu): zeigt, mit welchem Nextcloud-Konto
* der Benutzer angemeldet ist, und trennt die Verbindung nach einer Rueckfrage.
* Die API widerruft den Zugang bei Nextcloud; die Dateien dort bleiben unveraendert.
*/
export function AccountBar({
account,
host,
onDisconnected,
}: {
account: NextcloudFilesAccount;
host: string | null;
onDisconnected: () => void;
}) {
const t = useTranslations('nextcloudFiles.account');
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const name = account.displayName ?? account.ncUserId ?? '';
const disconnect = async () => {
setBusy(true);
setError(null);
try {
await disconnectNextcloud();
setConfirming(false);
onDisconnected();
} catch (err) {
// 409 notConnected: bereits getrennt, die Seite soll den Stand neu laden.
if (err instanceof NextcloudFilesRequestError && err.code === 'notConnected') {
setConfirming(false);
onDisconnected();
} else {
setError(t('disconnectFailed'));
}
} finally {
setBusy(false);
}
};
if (confirming) {
return (
<div
role="alertdialog"
aria-label={t('confirm.title')}
className="flex flex-wrap items-center justify-end gap-2"
>
<p className="max-w-md text-right text-sm text-foreground">
<span className="font-medium">{t('confirm.title')}</span> {t('confirm.body')}
</p>
{error && (
<p role="alert" className="w-full text-right text-sm text-destructive">
{error}
</p>
)}
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => setConfirming(false)}
>
{t('confirm.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void disconnect()}
>
{t('confirm.confirm')}
</button>
</div>
);
}
return (
<div className="flex flex-wrap items-center justify-end gap-3">
<div className="min-w-0 text-right">
<p className="truncate text-sm font-medium text-foreground">{t('signedInAs', { name })}</p>
{host && <p className="truncate text-xs text-muted-foreground">{host}</p>}
</div>
<button type="button" className="btn btn-secondary" onClick={() => setConfirming(true)}>
{t('disconnect')}
</button>
</div>
);
}
@@ -0,0 +1,332 @@
'use client';
import { useTranslations } from 'next-intl';
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import {
cancelLoginFlow,
connectWithPassword,
type NextcloudFilesFlowStart,
NextcloudFilesRequestError,
pollLoginFlow,
startLoginFlow,
} from '@/lib/nextcloud-files-api';
const POLL_INTERVAL_MS = 2000;
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';
interface ErrorState {
code: string | null;
message: string;
retryAfterSeconds: number | null;
}
/**
* Verbindungsbildschirm (quick-261008-mzu, L-02/L-03): ein Benutzer verbindet sein
* eigenes Nextcloud-Konto. Zwei Wege:
* 1. Benutzername und Passwort. Tessera speichert das Passwort nicht, sondern
* laesst sich einen eigenen Zugang ausstellen.
* 2. "Im Browser anmelden" (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung.
* Der Link zur Nextcloud ist ein gewoehnlicher Verweis, den der Benutzer
* SELBST anklickt — nie ein per Skript nach einem asynchronen Aufruf
* geoeffnetes Fenster (Popup-Sperren; die Desktop-App oeffnet
* `target=_blank` ueber ihren Dokument-Helfer). Waehrenddessen fragt die
* Seite alle 2 s die API, die ihrerseits Nextcloud befragt.
*/
export function ConnectPanel({
host,
expired,
onConnected,
}: {
host: string | null;
expired: boolean;
onConnected: () => void;
}) {
const t = useTranslations('nextcloudFiles.connect');
const [loginName, setLoginName] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<ErrorState | null>(null);
const [flow, setFlow] = useState<NextcloudFilesFlowStart | null>(null);
const toError = useCallback(
(err: unknown): ErrorState => {
if (err instanceof NextcloudFilesRequestError) {
const retry = Number(err.extra.retryAfterSeconds);
return {
code: err.code,
message: err.message,
retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? retry : null,
};
}
return { code: null, message: t('errors.generic'), retryAfterSeconds: null };
},
[t],
);
const errorText = (state: ErrorState): string => {
const minutes = Math.max(1, Math.ceil((state.retryAfterSeconds ?? 60) / 60));
switch (state.code) {
case 'credentialsOrTwoFactor':
return t('errors.credentialsOrTwoFactor');
case 'useBrowserLogin':
return t('errors.useBrowserLogin');
case 'tooManyAttempts':
return t('errors.tooManyAttempts', { minutes });
case 'nextcloudLocked':
return t('errors.nextcloudLocked', { minutes });
case 'flowExpired':
return t('errors.flowExpired');
case 'nextcloudUnavailable':
return t('errors.nextcloudUnavailable');
case 'tooManyFlows':
return t('errors.tooManyFlows');
default:
return state.message || t('errors.generic');
}
};
// --- Passwort ----------------------------------------------------------------
const submit = async (event: FormEvent) => {
event.preventDefault();
if (busy) return;
const name = loginName.trim();
if (name === '' || password === '') return;
setBusy(true);
setError(null);
try {
await connectWithPassword({ loginName: name, password });
setPassword('');
onConnected();
} catch (err) {
setError(toError(err));
} finally {
// Das Passwort bleibt nach keinem Versuch im Formular stehen.
setPassword('');
setBusy(false);
}
};
// --- Browser-Anmeldung ----------------------------------------------------------
const flowRef = useRef<NextcloudFilesFlowStart | null>(null);
flowRef.current = flow;
// Stabile Verweise, damit die Abfrageschleife nicht bei jedem Neuaufbau der Seite neu startet.
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
const toErrorRef = useRef(toError);
toErrorRef.current = toError;
const begin = async () => {
if (starting || busy) return;
setStarting(true);
setError(null);
try {
setFlow(await startLoginFlow());
} catch (err) {
setError(toError(err));
} finally {
setStarting(false);
}
};
const cancel = async () => {
const current = flowRef.current;
setFlow(null);
if (current) {
try {
await cancelLoginFlow(current.flowId);
} catch {
// Der Ablauf ist serverseitig ohnehin begrenzt; ein Fehler beim Abbrechen ist unerheblich.
}
}
};
useEffect(() => {
if (!flow) return;
const flowId = flow.flowId;
let stopped = false;
let inFlight = false;
const stop = (next: ErrorState | null) => {
stopped = true;
setFlow(null);
if (next) setError(next);
};
const poll = async () => {
if (stopped || inFlight) return;
inFlight = true;
try {
const result = await pollLoginFlow(flowId);
if (stopped) return;
if (result.state === 'connected') {
stopped = true;
setFlow(null);
onConnectedRef.current();
} else if (result.state === 'failed') {
stop({ code: result.code, message: result.message, retryAfterSeconds: null });
}
} catch (err) {
if (stopped) return;
if (err instanceof NextcloudFilesRequestError) {
if (err.status === 404 || err.status === 410) {
// Abgelaufen oder unbekannt (z. B. nach einem Neustart der API): von vorn beginnen.
stop({ code: 'flowExpired', message: err.message, retryAfterSeconds: null });
} else if (err.code === 'nextcloudLocked' || (err.status < 500 && err.status !== 429)) {
stop(toErrorRef.current(err));
}
// Andere Serverfehler sind meist vorübergehend: beim nächsten Takt erneut versuchen.
}
// Netzwerkaussetzer: ebenfalls beim nächsten Takt erneut versuchen.
} finally {
inFlight = false;
}
};
const timer = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') void poll();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
stopped = true;
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [flow]);
// Beim Verlassen der Seite einen noch offenen Ablauf freigeben.
useEffect(() => {
return () => {
const open = flowRef.current;
if (open) void cancelLoginFlow(open.flowId).catch(() => undefined);
};
}, []);
const preferBrowser =
error?.code === 'credentialsOrTwoFactor' || error?.code === 'useBrowserLogin';
const primaryClass = 'btn btn-primary w-full justify-center';
const secondaryClass = 'btn btn-secondary w-full justify-center';
return (
<div data-testid="nextcloud-files-connect" className="mx-auto w-full max-w-lg">
<div className="surface space-y-5 p-6">
<div>
{host && <p className="text-xs font-medium text-muted-foreground">{host}</p>}
<h2 className="mt-0.5 text-lg font-semibold text-foreground">{t('title')}</h2>
</div>
{expired && (
<p
role="status"
className="rounded border border-status-warn/40 bg-status-warn/12 px-3 py-2 text-sm text-status-warn-fg"
>
{t('expiredNotice')}
</p>
)}
{flow ? (
<div className="space-y-4" data-testid="nextcloud-files-flow">
<p className="text-sm text-foreground">{t('waiting.instruction')}</p>
<a
href={flow.loginUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary w-full justify-center"
>
{t('waiting.open')}
</a>
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full bg-primary-strong motion-safe:animate-pulse"
/>
{t('waiting.status')}
</p>
<button type="button" className="btn btn-subtle" onClick={() => void cancel()}>
{t('waiting.cancel')}
</button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">{t('explanation')}</p>
<form onSubmit={(e) => void submit(e)} className="space-y-4">
<div className="space-y-1.5">
<label
htmlFor="nc-files-login-name"
className="block text-sm font-medium text-foreground"
>
{t('loginName')}
</label>
<input
id="nc-files-login-name"
type="text"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
value={loginName}
onChange={(e) => setLoginName(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<div className="space-y-1.5">
<label
htmlFor="nc-files-password"
className="block text-sm font-medium text-foreground"
>
{t('password')}
</label>
<input
id="nc-files-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{errorText(error)}
</p>
)}
<button
type="submit"
className={preferBrowser ? secondaryClass : primaryClass}
disabled={busy || loginName.trim() === '' || password === ''}
>
{busy ? t('submitting') : t('submit')}
</button>
</form>
<div
className="flex items-center gap-3 text-xs text-muted-foreground"
aria-hidden="true"
>
<span className="h-px flex-1 bg-border" />
{t('or')}
<span className="h-px flex-1 bg-border" />
</div>
<div className="space-y-1.5">
<button
type="button"
className={preferBrowser ? primaryClass : secondaryClass}
disabled={starting || busy}
onClick={() => void begin()}
>
{starting ? t('starting') : t('browser')}
</button>
<p className="text-center text-xs text-muted-foreground">{t('browserHint')}</p>
</div>
</>
)}
</div>
</div>
);
}
@@ -1,8 +1,20 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import {
act,
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 {
type NextcloudFilesAccount,
NextcloudFilesRequestError,
type NextcloudFilesSettings,
type NextcloudFilesStatus,
} from '@/lib/nextcloud-files-api';
import de from '@/messages/de.json';
import NextcloudFilesPage from './page';
@@ -18,6 +30,11 @@ const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestSettings = vi.fn();
const mockConnect = vi.fn();
const mockStartFlow = vi.fn();
const mockPollFlow = vi.fn();
const mockCancelFlow = vi.fn();
const mockDisconnect = vi.fn();
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
@@ -27,6 +44,11 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args),
saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args),
testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...args),
connectWithPassword: (...args: unknown[]) => mockConnect(...args),
startLoginFlow: (...args: unknown[]) => mockStartFlow(...args),
pollLoginFlow: (...args: unknown[]) => mockPollFlow(...args),
cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args),
disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args),
};
});
@@ -55,6 +77,11 @@ beforeEach(() => {
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset();
mockTestSettings.mockReset();
mockConnect.mockReset();
mockStartFlow.mockReset();
mockPollFlow.mockReset();
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
mockDisconnect.mockReset();
});
afterEach(() => cleanup());
@@ -196,3 +223,246 @@ describe('SettingsTab', () => {
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
});
});
// --- Aufgabe 2: Verbinden, Browser-Anmeldung, Abmelden ------------------------------------
function account(over: Partial<NextcloudFilesAccount> = {}): NextcloudFilesAccount {
return {
connected: true,
expired: false,
status: 'ACTIVE',
ncUserId: 'anna',
displayName: 'Anna Müller',
connectedVia: 'PASSWORD',
connectedAt: '2026-10-08T10:00:00.000Z',
...over,
};
}
const LOGIN_URL = 'http://cloud.example/index.php/login/v2/flow/abc';
describe('ConnectPanel', () => {
it('eingerichtet ohne Konto: Verbindungsbildschirm mit Feldern, Anmelden und "Im Browser anmelden"', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByLabelText('Benutzername oder E-Mail')).toBeTruthy();
expect(screen.getByLabelText('Passwort')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Anmelden' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy();
expect(screen.queryByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeNull();
});
it('Anmelden ruft die API einmal mit dem getrimmten Namen auf, leert das Passwort und laedt den Stand neu', async () => {
mockConnect.mockResolvedValue(status({ account: account() }));
render(<NextcloudFilesPage />);
const name = await screen.findByLabelText('Benutzername oder E-Mail');
const pass = screen.getByLabelText('Passwort') as HTMLInputElement;
fireEvent.change(name, { target: { value: ' anna ' } });
fireEvent.change(pass, { target: { value: 'geheim' } });
mockGetStatus.mockResolvedValue(status({ account: account() }));
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
await waitFor(() => expect(mockConnect).toHaveBeenCalledTimes(1));
expect(mockConnect).toHaveBeenCalledWith({ loginName: 'anna', password: 'geheim' });
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
expect(screen.queryByLabelText('Passwort')).toBeNull();
});
it('nach einem Fehlschlag ist das Passwortfeld leer', async () => {
mockConnect.mockRejectedValue(
new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'),
);
render(<NextcloudFilesPage />);
const pass = (await screen.findByLabelText('Passwort')) as HTMLInputElement;
fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), {
target: { value: 'zoe' },
});
fireEvent.change(pass, { target: { value: 'falsch' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
await screen.findByRole('alert');
expect((screen.getByLabelText('Passwort') as HTMLInputElement).value).toBe('');
});
it('credentialsOrTwoFactor erklaert und macht "Im Browser anmelden" zur Hauptaktion', async () => {
mockConnect.mockRejectedValue(
new NextcloudFilesRequestError(422, 'credentialsOrTwoFactor', 'x'),
);
render(<NextcloudFilesPage />);
const browser = await screen.findByRole('button', { name: 'Im Browser anmelden' });
expect(browser.className).toContain('btn-secondary');
fireEvent.change(screen.getByLabelText('Benutzername oder E-Mail'), {
target: { value: 'zoe' },
});
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText(/Zwei-Faktor-Anmeldung/, { selector: 'p[role=alert]' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Im Browser anmelden' }).className).toContain(
'btn-primary',
);
});
it('tooManyAttempts zeigt die Wartezeit in Minuten, nextcloudLocked einen eigenen Text', async () => {
mockConnect.mockRejectedValueOnce(
new NextcloudFilesRequestError(429, 'tooManyAttempts', 'x', { retryAfterSeconds: 840 }),
);
render(<NextcloudFilesPage />);
fireEvent.change(await screen.findByLabelText('Benutzername oder E-Mail'), {
target: { value: 'anna' },
});
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText('Zu viele Fehlversuche. Bitte warten Sie 14 Minuten.'),
).toBeTruthy();
mockConnect.mockRejectedValueOnce(
new NextcloudFilesRequestError(503, 'nextcloudLocked', 'x', { retryAfterSeconds: 600 }),
);
fireEvent.change(screen.getByLabelText('Passwort'), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
expect(
await screen.findByText(/Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend/),
).toBeTruthy();
expect(screen.getByText(/10 Minuten erneut/)).toBeTruthy();
});
it('abgelaufenes Konto: Hinweis ueber dem Formular', async () => {
mockGetStatus.mockResolvedValue(
status({ account: account({ connected: false, expired: true, status: 'EXPIRED' }) }),
);
render(<NextcloudFilesPage />);
expect(await screen.findByText(/abgelaufen oder wurde in Nextcloud widerrufen/)).toBeTruthy();
expect(screen.getByLabelText('Passwort')).toBeTruthy();
});
});
describe('Browser-Anmeldung', () => {
let openSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
mockStartFlow.mockResolvedValue({
flowId: 'f1',
loginUrl: LOGIN_URL,
expiresAt: '2026-10-08T12:00:00.000Z',
});
mockPollFlow.mockResolvedValue({ state: 'pending' });
});
afterEach(() => {
vi.useRealTimers();
openSpy.mockRestore();
});
async function startFlow() {
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Im Browser anmelden' }));
return screen.findByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' });
}
it('startet den Ablauf einmal und zeigt einen echten Verweis (target _blank), ohne window.open', async () => {
const link = (await startFlow()) as HTMLAnchorElement;
expect(mockStartFlow).toHaveBeenCalledTimes(1);
expect(link.getAttribute('href')).toBe(LOGIN_URL);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
expect(screen.getByText('Warten auf Bestätigung in Nextcloud …')).toBeTruthy();
expect(openSpy).not.toHaveBeenCalled();
});
it('fragt alle 2000 ms ab und stoppt bei "connected" (Stand wird neu geladen)', async () => {
await startFlow();
expect(mockPollFlow).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(mockPollFlow).toHaveBeenCalledTimes(1);
expect(mockPollFlow).toHaveBeenCalledWith('f1');
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(mockPollFlow).toHaveBeenCalledTimes(2);
mockPollFlow.mockResolvedValue({ state: 'connected' });
mockGetStatus.mockResolvedValue(
status({ account: account({ connectedVia: 'LOGIN_FLOW', displayName: 'Zwei Faktor' }) }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(await screen.findByText('Angemeldet als Zwei Faktor')).toBeTruthy();
const after = mockPollFlow.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(mockPollFlow.mock.calls.length).toBe(after);
});
it('Abbrechen ruft cancelLoginFlow auf und beendet die Abfrage', async () => {
await startFlow();
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
await waitFor(() => expect(mockCancelFlow).toHaveBeenCalledWith('f1'));
expect(await screen.findByRole('button', { name: 'Im Browser anmelden' })).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(6000);
});
expect(mockPollFlow).not.toHaveBeenCalled();
});
it('flowExpired (410) beendet die Abfrage mit Hinweis', async () => {
await startFlow();
mockPollFlow.mockRejectedValue(new NextcloudFilesRequestError(410, 'flowExpired', 'x'));
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(
await screen.findByText('Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.'),
).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Anmeldung bei Nextcloud öffnen' })).toBeNull();
});
it('"failed" beendet die Abfrage und zeigt den Text', async () => {
await startFlow();
mockPollFlow.mockResolvedValue({
state: 'failed',
code: 'nextcloudUnavailable',
message: 'm',
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(await screen.findByText(/Nextcloud ist nicht erreichbar/)).toBeTruthy();
});
});
describe('AccountBar', () => {
beforeEach(() => {
mockGetStatus.mockResolvedValue(status({ account: account() }));
});
it('zeigt "Angemeldet als ..." und den Host', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
expect(screen.getByText('cloud.example')).toBeTruthy();
expect(screen.getByTestId('nextcloud-files-browser')).toBeTruthy();
});
it('Abmelden fragt nach und ruft erst nach "Trennen" disconnectNextcloud auf', async () => {
mockDisconnect.mockResolvedValue({ disconnected: true });
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' }));
expect(mockDisconnect).not.toHaveBeenCalled();
expect(screen.getByText(/Tessera vergisst den Zugang/)).toBeTruthy();
mockGetStatus.mockResolvedValue(status());
fireEvent.click(screen.getByRole('button', { name: 'Trennen' }));
await waitFor(() => expect(mockDisconnect).toHaveBeenCalledTimes(1));
expect(await screen.findByLabelText('Passwort')).toBeTruthy();
});
it('Abbrechen in der Rueckfrage trennt nichts', async () => {
render(<NextcloudFilesPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Abmelden' }));
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
expect(mockDisconnect).not.toHaveBeenCalled();
expect(screen.getByText('Angemeldet als Anna Müller')).toBeTruthy();
});
});
@@ -7,6 +7,8 @@ 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 { AccountBar } from './components/AccountBar';
import { ConnectPanel } from './components/ConnectPanel';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'files' | 'settings';
@@ -15,9 +17,10 @@ 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.
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im
* Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto
* (ConnectPanel); ist es verbunden, steht dort der Dateibereich
* (`nextcloud-files-browser`, den die Dateiansicht fuellt).
*/
export default function NextcloudFilesPage() {
const t = useTranslations('nextcloudFiles');
@@ -40,13 +43,25 @@ export default function NextcloudFilesPage() {
void reloadStatus();
}, [reloadStatus]);
const account = status?.account ?? null;
const connected = account?.connected === true;
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')} />
<PageHeader
moduleSlug="nextcloud-files"
title={t('title')}
description={t('description')}
actions={
activeTab === 'files' && status?.configured && account && connected ? (
<AccountBar account={account} host={status.host} onDisconnected={reloadStatus} />
) : undefined
}
/>
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
@@ -71,9 +86,19 @@ export default function NextcloudFilesPage() {
)}
{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>
{connected ? (
<div data-testid="nextcloud-files-browser">
<p className="text-sm text-muted-foreground">
{t('main.server', { host: status.host ?? '' })}
</p>
</div>
) : (
<ConnectPanel
host={status.host}
expired={account?.expired === true}
onConnected={reloadStatus}
/>
)}
</section>
)}
{activeTab === 'settings' && canManage && (