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