feat(nextcloud-files): Nextcloud-Kennung auf der Anmeldeseite, Anleitungen und Changelog

- Anmeldebildschirm zeigt Name, Logo und Themenfarbe der Nextcloud (GET server, server/logo)
- Kennung wird 10 Minuten zwischengespeichert, Adresswechsel leert sie, Logo nur nach Bytes erkannt und mit CSP-Sandbox ausgeliefert
- Anmeldekarte neu gestaltet (Kennungskopf, Hinweis zum Passwort am Fuss), Kontoleiste mit kleiner Kachel
- Fokusfang in leeren Ordnern, damit die Rücktaste dort funktioniert
- Changelog, Anwender-, Administrations- und Betriebshandbuch (Brute-Force-Ausnahme, Proxy-Grenzen)
- e2e-Skripte wiederholbar gemacht

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:08:57 +02:00
parent 86fbe8f396
commit 630398fe93
24 changed files with 1118 additions and 107 deletions
@@ -6,7 +6,9 @@ import {
disconnectNextcloud,
type NextcloudFilesAccount,
NextcloudFilesRequestError,
type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api';
import { ServerTile } from './ServerIdentity';
/**
* Kontoleiste im Seitenkopf (quick-261008-mzu): zeigt, mit welchem Nextcloud-Konto
@@ -16,10 +18,13 @@ import {
export function AccountBar({
account,
host,
info = null,
onDisconnected,
}: {
account: NextcloudFilesAccount;
host: string | null;
/** Kennung der Nextcloud fuer die kleine Kachel; ohne sie zeigt die Kachel nur den Anfangsbuchstaben. */
info?: NextcloudServerInfo | null;
onDisconnected: () => void;
}) {
const t = useTranslations('nextcloudFiles.account');
@@ -86,6 +91,9 @@ export function AccountBar({
return (
<div className="flex flex-wrap items-center justify-end gap-3">
<span title={info?.name ?? undefined} className="inline-flex">
<ServerTile info={info} size="sm" />
</span>
<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>}
@@ -7,9 +7,12 @@ import {
connectWithPassword,
type NextcloudFilesFlowStart,
NextcloudFilesRequestError,
type NextcloudServerInfo,
pollLoginFlow,
startLoginFlow,
} from '@/lib/nextcloud-files-api';
import { LockIcon } from './icons';
import { ServerIdentity } from './ServerIdentity';
const POLL_INTERVAL_MS = 2000;
@@ -36,10 +39,13 @@ interface ErrorState {
*/
export function ConnectPanel({
host,
info = null,
expired,
onConnected,
}: {
host: string | null;
/** Name, Farbe und Logo der Nextcloud; solange sie fehlen, steht nur der Rechnername da. */
info?: NextcloudServerInfo | null;
expired: boolean;
onConnected: () => void;
}) {
@@ -213,119 +219,137 @@ export function ConnectPanel({
const primaryClass = 'btn btn-primary w-full justify-center';
const secondaryClass = 'btn btn-secondary w-full justify-center';
const accent = info?.color ?? null;
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 className="surface overflow-hidden">
<div
className="border-b border-border bg-well px-6 py-5"
style={accent ? { boxShadow: `inset 0 3px 0 ${accent}` } : undefined}
>
<ServerIdentity info={info} host={host} />
</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 className="space-y-5 p-6">
<div>
<h2 className="text-lg font-semibold text-foreground">{t('title')}</h2>
{!flow && <p className="mt-1 text-sm text-muted-foreground">{t('explanation')}</p>}
</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"
{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"
>
<span className="h-px flex-1 bg-border" />
{t('or')}
<span className="h-px flex-1 bg-border" />
</div>
{t('expiredNotice')}
</p>
)}
<div className="space-y-1.5">
<button
type="button"
className={preferBrowser ? primaryClass : secondaryClass}
disabled={starting || busy}
onClick={() => void begin()}
{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"
>
{starting ? t('starting') : t('browser')}
{t('waiting.open')}
</a>
<p
role="status"
className="flex items-center gap-2.5 rounded-md bg-well px-3 py-2.5 text-sm text-muted-foreground"
>
<span
aria-hidden="true"
className="inline-block h-2 w-2 shrink-0 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>
<p className="text-center text-xs text-muted-foreground">{t('browserHint')}</p>
</div>
</>
)}
) : (
<>
<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>
<p className="flex items-start gap-2.5 border-t border-border bg-well px-6 py-4 text-xs leading-relaxed text-muted-foreground">
<LockIcon size={14} className="mt-0.5 shrink-0" />
{t('privacy')}
</p>
</div>
</div>
);
@@ -836,7 +836,9 @@ export function FileBrowser({
ref={rootRef}
aria-label={t('regionLabel')}
onKeyDown={onKeyDown}
className="surface relative max-sm:-mx-3 max-sm:rounded-none"
// Auffangpunkt fuer den Fokus, wenn es keine Zeile gibt (leerer Ordner): so geht die Rücktaste auch dort.
tabIndex={-1}
className="nc-files-root surface relative max-sm:-mx-3 max-sm:rounded-none"
>
<div
ref={barRef}
@@ -0,0 +1,88 @@
'use client';
import { useEffect, useState } from 'react';
import { readableOnAccent } from '@/lib/color';
import { type NextcloudServerInfo, serverLogoUrl } from '@/lib/nextcloud-files-api';
const TILE = {
lg: { box: 'h-12 w-12 rounded-xl', pad: 'p-2', letter: 'text-xl' },
sm: { box: 'h-8 w-8 rounded-lg', pad: 'p-1.5', letter: 'text-sm' },
} as const;
/**
* Kachel der Nextcloud (quick-261008-mzu, L-09): Themenfarbe der Nextcloud mit
* deren Logo. Ohne Logo (oder wenn es nicht laedt) steht der erste Buchstabe des
* Namens in einer lesbaren Schriftfarbe auf der Kachel; ohne Themenfarbe bleibt
* die Kachel neutral (`bg-tile`).
*/
export function ServerTile({
info,
size = 'lg',
}: {
info: NextcloudServerInfo | null;
size?: 'lg' | 'sm';
}) {
const name = info?.name?.trim() || 'Nextcloud';
const color = info?.color ?? null;
const logoToken = info ? `${info.host}-${info.version ?? ''}-${info.color ?? ''}` : undefined;
const [logoFailed, setLogoFailed] = useState(false);
// Neue Nextcloud (oder neu geladene Kennung): dem Logo eine neue Chance geben.
// biome-ignore lint/correctness/useExhaustiveDependencies: nur das Logo-Merkmal setzt den Fehler zurueck
useEffect(() => setLogoFailed(false), [logoToken]);
const t = TILE[size];
const showLogo = info?.hasLogo === true && !logoFailed;
const letter = [...name][0]?.toUpperCase() ?? 'N';
return (
<span
data-testid="nc-server-tile"
className={`relative inline-flex shrink-0 items-center justify-center overflow-hidden ${t.box} ${
color ? '' : 'bg-tile text-tile-foreground'
}`}
style={color ? { backgroundColor: color, color: readableOnAccent(color) } : undefined}
>
{showLogo ? (
// biome-ignore lint/performance/noImgElement: Logo kommt ueber den Tessera-Proxy, next/image passt nicht
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall
<img
src={serverLogoUrl(logoToken)}
alt=""
decoding="async"
className={`h-full w-full object-contain ${t.pad}`}
onError={() => setLogoFailed(true)}
/>
) : (
<span aria-hidden="true" className={`font-semibold leading-none ${t.letter}`}>
{letter}
</span>
)}
</span>
);
}
/** Kennung der Nextcloud: Kachel, daneben Name und Rechnername (so sieht man, WO man sich anmeldet). */
export function ServerIdentity({
info,
host,
}: {
info: NextcloudServerInfo | null;
/** Rechnername aus dem Status, solange die Kennung noch nicht geladen ist. */
host: string | null;
}) {
const name = info?.name?.trim() || 'Nextcloud';
const shownHost = info?.host ?? host;
return (
<div data-testid="nc-server-identity" className="flex min-w-0 items-center gap-3">
<ServerTile info={info} size="lg" />
<span className="min-w-0">
<span className="block truncate text-base font-semibold leading-tight text-foreground">
{name}
</span>
{shownHost && (
<span className="block truncate text-xs text-muted-foreground">{shownHost}</span>
)}
</span>
</div>
);
}
@@ -149,6 +149,18 @@ export const RetryIcon = (p: P) => (
<path d="M8 16H3v5" />
</Icon>
);
export const LockIcon = (p: P) => (
<Icon {...p}>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</Icon>
);
export const CloudIcon = (p: P) => (
<Icon {...p}>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</Icon>
);
export const FileGlyph = (p: P) => (
<Icon {...p}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
@@ -15,6 +15,7 @@ import {
NextcloudFilesRequestError,
type NextcloudFilesSettings,
type NextcloudFilesStatus,
type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api';
import de from '@/messages/de.json';
import NextcloudFilesPage from './page';
@@ -37,6 +38,7 @@ const mockPollFlow = vi.fn();
const mockCancelFlow = vi.fn();
const mockDisconnect = vi.fn();
const mockListFolder = vi.fn();
const mockGetServerInfo = vi.fn();
vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-files-api')>();
@@ -52,6 +54,7 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => {
cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args),
disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args),
listFolder: (...args: unknown[]) => mockListFolder(...args),
getServerInfo: (...args: unknown[]) => mockGetServerInfo(...args),
};
});
@@ -70,6 +73,17 @@ function status(over: Partial<NextcloudFilesStatus> = {}): NextcloudFilesStatus
};
}
function serverInfo(over: Partial<NextcloudServerInfo> = {}): NextcloudServerInfo {
return {
host: 'cloud.example',
name: 'Firmen-Cloud',
color: '#0082c9',
version: '34.0.4',
hasLogo: false,
...over,
};
}
function settings(over: Partial<NextcloudFilesSettings> = {}): NextcloudFilesSettings {
return { baseUrl: 'https://cloud.example', connectedAccounts: 0, ...over };
}
@@ -85,6 +99,7 @@ beforeEach(() => {
mockPollFlow.mockReset();
mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true });
mockDisconnect.mockReset();
mockGetServerInfo.mockReset().mockResolvedValue(serverInfo());
mockListFolder.mockReset().mockResolvedValue({
path: '/',
entries: [
@@ -518,3 +533,58 @@ describe('Dateiansicht auf der Seite', () => {
expect(mockGetStatus).toHaveBeenCalledTimes(2);
});
});
describe('Kennung der Nextcloud (Aufgabe 6)', () => {
it('Anmeldebildschirm zeigt Name und Rechnername, die Kachel in der Themenfarbe, ohne Logo den Anfangsbuchstaben', async () => {
render(<NextcloudFilesPage />);
const identity = await screen.findByTestId('nc-server-identity');
await waitFor(() => expect(within(identity).getByText('Firmen-Cloud')).toBeTruthy());
expect(within(identity).getByText('cloud.example')).toBeTruthy();
const tile = within(identity).getByTestId('nc-server-tile') as HTMLElement;
expect(tile.style.backgroundColor).toBe('rgb(0, 130, 201)');
expect(within(tile).getByText('F')).toBeTruthy();
expect(tile.querySelector('img')).toBeNull();
});
it('mit Logo steht ein Bild von der Tessera-Adresse in der Kachel; ein Ladefehler faellt auf den Buchstaben zurueck', async () => {
mockGetServerInfo.mockResolvedValue(serverInfo({ hasLogo: true }));
render(<NextcloudFilesPage />);
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
await waitFor(() => expect(tile.querySelector('img')).toBeTruthy());
const img = tile.querySelector('img') as HTMLImageElement;
expect(img.getAttribute('src')).toContain('/modules/nextcloud-files/server/logo');
fireEvent.error(img);
await waitFor(() => expect(tile.querySelector('img')).toBeNull());
expect(within(tile).getByText('F')).toBeTruthy();
});
it('ohne Themenfarbe bleibt die Kachel neutral', async () => {
mockGetServerInfo.mockResolvedValue(serverInfo({ color: null }));
render(<NextcloudFilesPage />);
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
await waitFor(() => expect(within(tile).getByText('F')).toBeTruthy());
expect(tile.className).toContain('bg-tile');
expect(tile.style.backgroundColor).toBe('');
});
it('scheitert die Kennung, steht nur der Rechnername da', async () => {
mockGetServerInfo.mockRejectedValue(new NextcloudFilesRequestError(500, null, 'x'));
render(<NextcloudFilesPage />);
const identity = await screen.findByTestId('nc-server-identity');
expect(within(identity).getByText('cloud.example')).toBeTruthy();
expect(within(identity).getByText('Nextcloud')).toBeTruthy();
});
it('die Kontoleiste zeigt die kleine Kachel der Nextcloud', async () => {
mockGetStatus.mockResolvedValue(status({ account: account() }));
render(<NextcloudFilesPage />);
expect(await screen.findByText('Angemeldet als Anna Müller')).toBeTruthy();
const tile = (await screen.findByTestId('nc-server-tile')) as HTMLElement;
await waitFor(() => expect(tile.style.backgroundColor).toBe('rgb(0, 130, 201)'));
});
it('der Datenschutzhinweis steht am Fuss des Anmeldebildschirms', async () => {
render(<NextcloudFilesPage />);
expect(await screen.findByText(/Tessera speichert Ihr Passwort nicht/)).toBeTruthy();
});
});
@@ -5,7 +5,12 @@ 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 {
getNextcloudFilesStatus,
getServerInfo,
type NextcloudFilesStatus,
type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountBar } from './components/AccountBar';
import { ConnectPanel } from './components/ConnectPanel';
@@ -31,6 +36,7 @@ export default function NextcloudFilesPage() {
const [status, setStatus] = useState<NextcloudFilesStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('files');
const [serverInfo, setServerInfo] = useState<NextcloudServerInfo | null>(null);
const reloadStatus = useCallback(async () => {
try {
@@ -45,6 +51,29 @@ export default function NextcloudFilesPage() {
void reloadStatus();
}, [reloadStatus]);
// Name, Farbe und Logo der Nextcloud fuer Anmeldebildschirm und Kontoleiste. Ein Fehler ist
// harmlos: ohne Kennung steht der Rechnername da.
const configured = status?.configured === true;
const serverUrl = status?.serverUrl ?? null;
// biome-ignore lint/correctness/useExhaustiveDependencies: eine neue Adresse laedt die Kennung neu
useEffect(() => {
if (!configured) {
setServerInfo(null);
return;
}
let cancelled = false;
getServerInfo()
.then((info) => {
if (!cancelled) setServerInfo(info);
})
.catch(() => {
if (!cancelled) setServerInfo(null);
});
return () => {
cancelled = true;
};
}, [configured, serverUrl]);
const account = status?.account ?? null;
const connected = account?.connected === true;
@@ -60,7 +89,12 @@ export default function NextcloudFilesPage() {
description={t('description')}
actions={
activeTab === 'files' && status?.configured && account && connected ? (
<AccountBar account={account} host={status.host} onDisconnected={reloadStatus} />
<AccountBar
account={account}
host={status.host}
info={serverInfo}
onDisconnected={reloadStatus}
/>
) : undefined
}
/>
@@ -95,6 +129,7 @@ export default function NextcloudFilesPage() {
) : (
<ConnectPanel
host={status.host}
info={serverInfo}
expired={account?.expired === true}
onConnected={reloadStatus}
/>
+10
View File
@@ -916,6 +916,16 @@ html[data-desktop-os="linux"] .animate-pulse {
outline-offset: -2px;
}
/* Die Dateikarte faengt den Fokus auf, wenn es keine Zeile gibt (leerer Ordner); sie soll dabei
keinen Rahmen zeigen, im dunklen Modus aber ihre Haarlinie behalten. */
.nc-files-root:focus {
outline: none;
}
.dark .nc-files-root:focus {
outline: 1px solid var(--border);
outline-offset: -1px;
}
/* Bewegung der Dateiansicht nur als Antwort auf eine Handlung (Auswahl,
Ablegen, erste Uebertragung); `motion-safe:` schaltet sie bei reduzierter
Bewegung ganz ab. */
+23
View File
@@ -138,6 +138,29 @@ export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFi
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
}
/** Kennung der Nextcloud fuer den Anmeldebildschirm (ohne Zugangsdaten). */
export interface NextcloudServerInfo {
host: string;
name: string;
/** `#rrggbb` aus dem Nextcloud-Theming oder `null`. */
color: string | null;
version: string | null;
hasLogo: boolean;
}
export function getServerInfo(): Promise<NextcloudServerInfo> {
return request<NextcloudServerInfo>('/server');
}
/**
* Adresse des Nextcloud-Logos als Bild (`<img>` laedt mit dem Tessera-Cookie). `token`
* wechselt, wenn sich die Nextcloud aendert, und umgeht so den Bildzwischenspeicher.
*/
export function serverLogoUrl(token?: string): string {
const version = token ? `?v=${encodeURIComponent(token)}` : '';
return `${API_URL}${BASE}/server/logo${version}`;
}
/**
* Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die
* API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis.
+2 -1
View File
@@ -2337,7 +2337,8 @@
"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.",
"explanation": "Melden Sie sich mit Ihrem Nextcloud-Konto an.",
"privacy": "Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit in Nextcloud widerrufen können.",
"loginName": "Benutzername oder E-Mail",
"password": "Passwort",
"submit": "Anmelden",
+2 -1
View File
@@ -2337,7 +2337,8 @@
"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.",
"explanation": "Sign in with your Nextcloud account.",
"privacy": "Tessera does not store your password. It asks Nextcloud for a separate access that you can revoke in Nextcloud at any time.",
"loginName": "User name or email",
"password": "Password",
"submit": "Sign in",