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:
@@ -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 && (
|
||||
|
||||
@@ -12,11 +12,25 @@ const BASE = '/modules/nextcloud-files';
|
||||
export interface NextcloudFilesAccount {
|
||||
connected: boolean;
|
||||
expired: boolean;
|
||||
status: 'ACTIVE' | 'EXPIRED';
|
||||
ncUserId: string | null;
|
||||
displayName: string | null;
|
||||
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
|
||||
connectedAt: string | null;
|
||||
}
|
||||
|
||||
export interface NextcloudFilesFlowStart {
|
||||
flowId: string;
|
||||
/** Link zur Anmeldeseite der Nextcloud; der Benutzer oeffnet ihn mit eigenem Klick. */
|
||||
loginUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type NextcloudFilesFlowPoll =
|
||||
| { state: 'pending' }
|
||||
| { state: 'connected' }
|
||||
| { state: 'failed'; code: string; message: string };
|
||||
|
||||
export interface NextcloudFilesStatus {
|
||||
configured: boolean;
|
||||
serverUrl: string | null;
|
||||
@@ -118,3 +132,34 @@ export function saveNextcloudFilesSettings(input: {
|
||||
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
|
||||
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die
|
||||
* API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis.
|
||||
*/
|
||||
export function connectWithPassword(input: {
|
||||
loginName: string;
|
||||
password: string;
|
||||
}): Promise<NextcloudFilesStatus> {
|
||||
return request<NextcloudFilesStatus>('/connect/password', { method: 'POST', json: input });
|
||||
}
|
||||
|
||||
/** Startet die Browser-Anmeldung (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. */
|
||||
export function startLoginFlow(): Promise<NextcloudFilesFlowStart> {
|
||||
return request<NextcloudFilesFlowStart>('/connect/flow', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function pollLoginFlow(flowId: string): Promise<NextcloudFilesFlowPoll> {
|
||||
return request<NextcloudFilesFlowPoll>(`/connect/flow/${encodeURIComponent(flowId)}`);
|
||||
}
|
||||
|
||||
export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> {
|
||||
return request<{ cancelled: true }>(`/connect/flow/${encodeURIComponent(flowId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/** Trennt die Verbindung; die API widerruft den Zugang bei Nextcloud. */
|
||||
export function disconnectNextcloud(): Promise<{ disconnected: true }> {
|
||||
return request<{ disconnected: true }>('/connect', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -2337,6 +2337,46 @@
|
||||
"main": {
|
||||
"server": "Nextcloud: {host}"
|
||||
},
|
||||
"connect": {
|
||||
"title": "Mit Nextcloud verbinden",
|
||||
"expiredNotice": "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an.",
|
||||
"explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.",
|
||||
"loginName": "Benutzername oder E-Mail",
|
||||
"password": "Passwort",
|
||||
"submit": "Anmelden",
|
||||
"submitting": "Melde an …",
|
||||
"or": "oder",
|
||||
"browser": "Im Browser anmelden",
|
||||
"starting": "Starte …",
|
||||
"browserHint": "Für Konten mit Zwei-Faktor-Anmeldung",
|
||||
"waiting": {
|
||||
"instruction": "Öffnen Sie die Anmeldung bei Nextcloud, melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“.",
|
||||
"open": "Anmeldung bei Nextcloud öffnen",
|
||||
"status": "Warten auf Bestätigung in Nextcloud …",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Die Anmeldung ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"credentialsOrTwoFactor": "Die Anmeldung hat nicht geklappt. Entweder stimmen Benutzername oder Passwort nicht, oder Ihr Konto nutzt die Zwei-Faktor-Anmeldung. Dann melden Sie sich bitte im Browser an.",
|
||||
"useBrowserLogin": "Nextcloud erlaubt für dieses Konto keine Anmeldung mit Passwort. Bitte melden Sie sich im Browser an.",
|
||||
"tooManyAttempts": "Zu viele Fehlversuche. Bitte warten Sie {minutes, plural, one {# Minute} other {# Minuten}}.",
|
||||
"nextcloudLocked": "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes, plural, one {# Minute} other {# Minuten}} erneut.",
|
||||
"flowExpired": "Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu.",
|
||||
"nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.",
|
||||
"tooManyFlows": "Zurzeit laufen zu viele Anmeldungen im Browser. Bitte versuchen Sie es gleich erneut."
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"signedInAs": "Angemeldet als {name}",
|
||||
"disconnect": "Abmelden",
|
||||
"disconnectFailed": "Die Verbindung konnte nicht getrennt werden. Bitte versuchen Sie es erneut.",
|
||||
"confirm": {
|
||||
"title": "Verbindung zu Nextcloud trennen?",
|
||||
"body": "Tessera vergisst den Zugang. Ihre Dateien in Nextcloud bleiben unverändert.",
|
||||
"confirm": "Trennen",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden."
|
||||
|
||||
@@ -2337,6 +2337,46 @@
|
||||
"main": {
|
||||
"server": "Nextcloud: {host}"
|
||||
},
|
||||
"connect": {
|
||||
"title": "Connect to Nextcloud",
|
||||
"expiredNotice": "Your connection to Nextcloud has expired or was revoked in Nextcloud. Please sign in again.",
|
||||
"explanation": "Sign in with your Nextcloud account. Tessera does not store your password; instead it has Nextcloud issue a separate access that you can revoke at any time.",
|
||||
"loginName": "User name or email",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in …",
|
||||
"or": "or",
|
||||
"browser": "Sign in with the browser",
|
||||
"starting": "Starting …",
|
||||
"browserHint": "For accounts with two-factor authentication",
|
||||
"waiting": {
|
||||
"instruction": "Open the Nextcloud sign-in, log in there and confirm with “Grant access”.",
|
||||
"open": "Open sign-in at Nextcloud",
|
||||
"status": "Waiting for confirmation in Nextcloud …",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Signing in failed. Please try again.",
|
||||
"credentialsOrTwoFactor": "Signing in did not work. Either the user name or password is wrong, or your account uses two-factor authentication. In that case, please sign in with the browser.",
|
||||
"useBrowserLogin": "Nextcloud does not allow password sign-in for this account. Please sign in with the browser.",
|
||||
"tooManyAttempts": "Too many failed attempts. Please wait {minutes, plural, one {# minute} other {# minutes}}.",
|
||||
"nextcloudLocked": "Nextcloud is temporarily blocking requests from the Tessera server. Please try again in {minutes, plural, one {# minute} other {# minutes}}.",
|
||||
"flowExpired": "The sign-in has expired. Please start it again.",
|
||||
"nextcloudUnavailable": "Nextcloud is unreachable or did not answer in time.",
|
||||
"tooManyFlows": "Too many browser sign-ins are running right now. Please try again in a moment."
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"signedInAs": "Signed in as {name}",
|
||||
"disconnect": "Sign out",
|
||||
"disconnectFailed": "The connection could not be removed. Please try again.",
|
||||
"confirm": {
|
||||
"title": "Disconnect from Nextcloud?",
|
||||
"body": "Tessera forgets the access. Your files in Nextcloud stay unchanged.",
|
||||
"confirm": "Disconnect",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the Nextcloud connection could not be loaded."
|
||||
|
||||
@@ -236,4 +236,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'dass',
|
||||
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
|
||||
'zuverlässig',
|
||||
// quick-261008-mzu (Aufgabe 2): Verbinden und Abmelden — korrektes Deutsch mit „ss“
|
||||
'ausstellen',
|
||||
'vergisst',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user