feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung

- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -0,0 +1,202 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
import de from '@/messages/de.json';
import { CloudForm } from './CloudForm';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const api = {
createInstance: vi.fn(),
updateInstance: vi.fn(),
deleteInstance: vi.fn(),
uploadLogo: vi.fn(),
removeLogo: vi.fn(),
};
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return {
...actual,
createInstance: (...a: unknown[]) => api.createInstance(...a),
updateInstance: (...a: unknown[]) => api.updateInstance(...a),
deleteInstance: (...a: unknown[]) => api.deleteInstance(...a),
uploadLogo: (...a: unknown[]) => api.uploadLogo(...a),
removeLogo: (...a: unknown[]) => api.removeLogo(...a),
};
});
const EXISTING: NextcloudInstance = {
id: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://a.example.de',
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: null,
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
};
function setup(instance: NextcloudInstance | null = null) {
const props = { onClose: vi.fn(), onSaved: vi.fn(), onDeleted: vi.fn() };
render(<CloudForm instance={instance} {...props} />);
return props;
}
describe('CloudForm', () => {
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
});
afterEach(() => cleanup());
it('verlangt Kundenname und Adresse', async () => {
setup();
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Bitte füllen Sie Kundenname und Adresse aus.',
);
expect(api.createInstance).not.toHaveBeenCalled();
});
it('legt eine Cloud an', async () => {
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: ' Neu GmbH ' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(props.onSaved).toHaveBeenCalled());
expect(api.createInstance).toHaveBeenCalledWith({
customerName: 'Neu GmbH',
baseUrl: 'https://cloud.neu.de',
});
expect(api.uploadLogo).not.toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalled();
});
it('lädt nach dem Anlegen das gewählte Logo hoch', async () => {
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
api.uploadLogo.mockResolvedValue({ ...EXISTING, id: 'neu', hasUploadedLogo: true });
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(api.uploadLogo).toHaveBeenCalledWith('neu', file));
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
});
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
api.createInstance.mockResolvedValue(EXISTING);
setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
);
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
fireEvent.change(
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
{
target: { value: 'https://logo.example.de/a.png' },
},
);
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.createInstance).toHaveBeenCalledWith({
customerName: 'Neu',
baseUrl: 'https://cloud.neu.de',
logoUrl: 'https://logo.example.de/a.png',
}),
);
});
it('lehnt eine zu große Datei sofort ab', async () => {
setup();
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
const big = new File([new Uint8Array(1024 * 1024 + 1)], 'groß.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
target: { files: [big] },
});
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
});
it('ändert eine vorhandene Cloud', async () => {
api.updateInstance.mockResolvedValue(EXISTING);
const props = setup(EXISTING);
expect(screen.getByLabelText('Kundenname')).toHaveValue('Kunde A');
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Kunde B' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.updateInstance).toHaveBeenCalledWith('i1', {
customerName: 'Kunde B',
baseUrl: 'https://a.example.de',
}),
);
expect(props.onSaved).toHaveBeenCalled();
});
it('entfernt ein hochgeladenes Logo über „Kein Logo“', async () => {
const withLogo = { ...EXISTING, hasUploadedLogo: true, logoVersion: 2 };
api.updateInstance.mockResolvedValue(withLogo);
api.removeLogo.mockResolvedValue(EXISTING);
setup(withLogo);
fireEvent.click(screen.getByRole('button', { name: 'Logo entfernen' }));
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(api.removeLogo).toHaveBeenCalledWith('i1'));
});
it('Entfernen fragt erst nach und ruft dann deleteInstance', async () => {
api.deleteInstance.mockResolvedValue(undefined);
const props = setup(EXISTING);
fireEvent.click(screen.getByRole('button', { name: 'Cloud entfernen' }));
expect(
screen.getByText('Möchten Sie die Cloud „Kunde A“ wirklich entfernen?'),
).toBeInTheDocument();
expect(api.deleteInstance).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Endgültig entfernen' }));
await waitFor(() => expect(api.deleteInstance).toHaveBeenCalledWith('i1'));
expect(props.onDeleted).toHaveBeenCalledWith('i1');
});
it('zeigt die Fehlermeldung der API im Formular', async () => {
api.createInstance.mockRejectedValue(
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
);
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Bitte geben Sie eine gültige Adresse',
);
expect(props.onSaved).not.toHaveBeenCalled();
expect(props.onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,297 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
createInstance,
deleteInstance,
logoSrc,
type NextcloudInstance,
removeLogo,
updateInstance,
uploadLogo,
} from '@/lib/nextcloud-status-api';
type LogoMode = 'none' | 'upload' | 'url';
/** Obergrenze fuer ein Logo (wie die API: 1 MiB). */
const LOGO_MAX_BYTES = 1024 * 1024;
interface CloudFormProps {
/** `null` = neue Cloud anlegen, sonst bearbeiten. */
instance: NextcloudInstance | null;
onClose: () => void;
/** Liefert die gespeicherte Cloud (mit frischer Pruefung und Bewertung). */
onSaved: (instance: NextcloudInstance) => void;
onDeleted: (id: string) => void;
}
function initialMode(instance: NextcloudInstance | null): LogoMode {
if (instance?.hasUploadedLogo) return 'upload';
if (instance?.logoUrl) return 'url';
return 'none';
}
/**
* Formular „Cloud hinzufuegen / bearbeiten“ (quick-261002-k67, L-02, L-09) —
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
* der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den
* Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt.
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
*/
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
const t = useTranslations('nextcloudStatus.form');
const [customerName, setCustomerName] = useState(instance?.customerName ?? '');
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? '');
const [mode, setMode] = useState<LogoMode>(initialMode(instance));
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
const [file, setFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentLogo =
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0] ?? null;
if (picked && picked.size > LOGO_MAX_BYTES) {
setError(t('logoFileTooLarge'));
setFile(null);
return;
}
setError(null);
setFile(picked);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const name = customerName.trim();
const url = baseUrl.trim();
if (!name || !url) {
setError(t('required'));
return;
}
setSaving(true);
try {
let saved: NextcloudInstance;
if (instance) {
const payload: { customerName: string; baseUrl: string; logoUrl?: string } = {
customerName: name,
baseUrl: url,
};
if (mode === 'url') payload.logoUrl = logoUrl.trim();
else if (mode === 'none' && instance.logoUrl) payload.logoUrl = '';
saved = await updateInstance(instance.id, payload);
if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id);
if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file);
} else {
saved = await createInstance({
customerName: name,
baseUrl: url,
...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}),
});
if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file);
}
onSaved(saved);
onClose();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t('saveError'));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!instance) return;
setError(null);
setSaving(true);
try {
await deleteInstance(instance.id);
onDeleted(instance.id);
onClose();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
setConfirmDelete(false);
} finally {
setSaving(false);
}
};
const input = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="cloud-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="cloud-form-title" className="mb-4 text-lg font-semibold text-foreground">
{instance ? t('editTitle') : t('addTitle')}
</h2>
{confirmDelete && instance ? (
<div className="space-y-4">
<p className="text-sm text-foreground">
{t('deleteConfirm', { name: instance.customerName })}
</p>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="btn btn-secondary"
>
{t('cancel')}
</button>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="btn btn-primary"
>
{t('deleteConfirmYes')}
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="space-y-2">
<label htmlFor="cloud-customer-name" className="text-sm font-medium text-foreground">
{t('customerName')}
</label>
<input
id="cloud-customer-name"
type="text"
required
maxLength={120}
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className={input}
/>
</div>
<div className="space-y-2">
<label htmlFor="cloud-base-url" className="text-sm font-medium text-foreground">
{t('baseUrl')}
</label>
<input
id="cloud-base-url"
type="text"
required
maxLength={2048}
placeholder={t('baseUrlPlaceholder')}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className={input}
/>
<p className="text-xs text-muted-foreground">{t('baseUrlHint')}</p>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-foreground">{t('logo')}</legend>
{currentLogo && mode !== 'none' && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* biome-ignore lint/performance/noImgElement: Vorschau einer externen oder hochgeladenen Adresse */}
<img
src={currentLogo}
alt={t('logoCurrent')}
referrerPolicy="no-referrer"
className="h-10 w-10 rounded-md bg-well object-contain"
/>
<button type="button" onClick={() => setMode('none')} className="btn btn-subtle">
{t('logoRemove')}
</button>
</div>
)}
<div className="flex flex-wrap gap-4 text-sm">
{(['none', 'upload', 'url'] as const).map((value) => (
<label key={value} className="flex items-center gap-2">
<input
type="radio"
name="cloud-logo-mode"
value={value}
checked={mode === value}
onChange={() => setMode(value)}
/>
{t(
value === 'none' ? 'logoNone' : value === 'upload' ? 'logoUpload' : 'logoUrl',
)}
</label>
))}
</div>
{mode === 'upload' && (
<div className="space-y-1">
<input
type="file"
aria-label={t('logoUpload')}
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFile}
className="block w-full text-sm"
/>
<p className="text-xs text-muted-foreground">{t('logoFileHint')}</p>
</div>
)}
{mode === 'url' && (
<div className="space-y-1">
<input
type="url"
aria-label={t('logoUrl')}
maxLength={2048}
placeholder={t('logoUrlPlaceholder')}
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
className={input}
/>
<p className="text-xs text-muted-foreground">{t('logoUrlHint')}</p>
</div>
)}
</fieldset>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div>
{instance && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="btn btn-subtle"
>
{t('delete')}
</button>
)}
</div>
<div className="flex gap-3">
<button type="button" onClick={onClose} className="btn btn-secondary">
{t('cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
</div>
</div>
</form>
)}
</div>
</div>
);
}
@@ -17,6 +17,12 @@ interface CloudTileProps {
instance: NextcloudInstance;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number;
/** Verwalten-Ebene: zeigt die Knoepfe Pruefen und Bearbeiten (nur Anzeige, bindend ist die API). */
canManage?: boolean;
/** Eine Pruefung dieser Kachel laeuft (Drehsymbol, Knopf gesperrt). */
checking?: boolean;
onCheck?: () => void;
onEdit?: () => void;
}
/**
@@ -26,7 +32,14 @@ interface CloudTileProps {
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
*/
export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
export function CloudTile({
instance,
now = Date.now(),
canManage = false,
checking = false,
onCheck,
onEdit,
}: CloudTileProps) {
const t = useTranslations('nextcloudStatus');
const locale = useLocale();
const [logoFailed, setLogoFailed] = useState(false);
@@ -79,6 +92,58 @@ export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
{instance.baseUrl}
</a>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onCheck}
disabled={checking}
aria-label={t('cardActions.refresh')}
title={t('cardActions.refresh')}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
</button>
<button
type="button"
onClick={onEdit}
aria-label={t('cardActions.edit')}
title={t('cardActions.edit')}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -1,4 +1,11 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -15,12 +22,29 @@ function render(ui: ReactElement) {
}
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) };
return {
...actual,
listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...args),
};
});
let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: 'u1' } }),
}));
function makeInstance(
over: Partial<NextcloudInstance> & { id: string; customerName: string },
): NextcloudInstance {
@@ -101,6 +125,10 @@ const LIST: NextcloudList = {
describe('NextcloudStatusPage', () => {
beforeEach(() => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockCanManage = false;
window.localStorage.clear();
});
afterEach(() => cleanup());
@@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => {
const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument();
});
describe('Rechte und Sortierung', () => {
const tileNames = () =>
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
});
it('Verwalter sehen alle Knöpfe', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
});
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
});
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
});
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
expect(mockCheckAll).toHaveBeenCalledTimes(1);
});
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckOne.mockResolvedValue({
...LIST.instances[2],
customerName: 'Kunde Rot',
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
});
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
const red = tiles.find((t) =>
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
) as HTMLElement;
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
await waitFor(() =>
expect(
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
).toHaveAttribute('data-level', 'green'),
);
});
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
});
@@ -1,10 +1,26 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageHeader } from '@/components/layout/page-header';
import {
readSortPreference,
SORT_KEYS,
type SortKey,
sortClouds,
writeSortPreference,
} from '@/components/nextcloud-status/sort-clouds';
import { WELL } from '@/components/proxmox/status-styles';
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api';
import {
checkAll,
checkOne,
listInstances,
type NextcloudInstance,
type NextcloudList,
} from '@/lib/nextcloud-status-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { CloudForm } from './components/CloudForm';
import { CloudTile } from './components/CloudTile';
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
@@ -25,16 +41,50 @@ function SkeletonTile() {
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
);
}
/**
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
*
* Sortierung (L-07) steht jedem Benutzer offen und wird je Benutzer im
* Browser gemerkt. Hinzufuegen, Bearbeiten, Pruefen („Jetzt pruefen“, je
* Kachel) nur mit Verwalten (`useCanManageModule`, L-09); die Knoepfe sind
* reine Anzeige — bindend ist `@ModuleManage` an der API.
*/
export default function NextcloudStatusPage() {
const t = useTranslations('nextcloudStatus');
const userId = useAuthStore((s) => s.user?.id ?? null);
const canManage = useCanManageModule('nextcloud-status') === true;
const [data, setData] = useState<NextcloudList | null>(null);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const [sortKey, setSortKey] = useState<SortKey>('name');
const [checkingAll, setCheckingAll] = useState(false);
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
const reload = useCallback(() => {
listInstances()
@@ -54,11 +104,82 @@ export default function NextcloudStatusPage() {
return () => window.clearInterval(id);
}, []);
// Gemerkte Sortierung dieses Benutzers anwenden, sobald seine Kennung feststeht.
useEffect(() => {
if (userId) setSortKey(readSortPreference(userId));
}, [userId]);
const handleSort = (key: SortKey) => {
setSortKey(key);
if (userId) writeSortPreference(userId, key);
};
const handleCheckAll = async () => {
setCheckingAll(true);
try {
const list = await checkAll();
setData(list);
setNow(Date.now());
} catch {
setError(t('loadError'));
} finally {
setCheckingAll(false);
}
};
const handleCheckOne = async (id: string) => {
setCheckingIds((prev) => new Set(prev).add(id));
try {
const updated = await checkOne(id);
setData((prev) =>
prev
? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) }
: prev,
);
setNow(Date.now());
} catch {
/* die Kachel behaelt ihren bisherigen Stand */
} finally {
setCheckingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
// neueste Version kommen so wieder aus einer Quelle.
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
const newest = data?.reference.newestVersion ?? null;
return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-status" title={t('title')} description={t('description')} />
<PageHeader
moduleSlug="nextcloud-status"
title={t('title')}
description={t('description')}
actions={
canManage ? (
<>
<button type="button" onClick={() => setEditing(null)} className="btn btn-subtle">
{t('actions.add')}
</button>
{data !== null && data.instances.length > 0 && (
<button
type="button"
onClick={handleCheckAll}
disabled={checkingAll}
className="btn btn-primary"
>
<RefreshIcon spinning={checkingAll} />
{checkingAll ? t('actions.checking') : t('actions.checkAll')}
</button>
)}
</>
) : undefined
}
/>
{error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
@@ -79,26 +200,62 @@ export default function NextcloudStatusPage() {
)}
{!error && data !== null && (
<p className="text-sm text-muted-foreground" data-testid="newest-version">
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground" data-testid="newest-version">
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
</p>
{data.instances.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('sort.label')}
<select
value={sortKey}
onChange={(e) => handleSort(e.target.value as SortKey)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
>
{SORT_KEYS.map((key) => (
<option key={key} value={key}>
{t(`sort.${key}`)}
</option>
))}
</select>
</label>
)}
</div>
)}
{!error && data !== null && data.instances.length === 0 && (
<div className={`${WELL} px-6 py-10 text-center`}>
<p className="text-sm text-muted-foreground">{t('empty')}</p>
{canManage && <p className="mt-1 text-sm text-muted-foreground">{t('emptyManage')}</p>}
</div>
)}
{!error && data !== null && data.instances.length > 0 && (
{!error && data !== null && sorted.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.instances.map((instance) => (
{sorted.map((instance) => (
<li key={instance.id} className="min-w-0">
<CloudTile instance={instance} now={now} />
<CloudTile
key={`${instance.id}:${instance.logoVersion}`}
instance={instance}
now={now}
canManage={canManage}
checking={checkingAll || checkingIds.has(instance.id)}
onCheck={() => handleCheckOne(instance.id)}
onEdit={() => setEditing(instance)}
/>
</li>
))}
</ul>
)}
{editing !== undefined && (
<CloudForm
instance={editing}
onClose={() => setEditing(undefined)}
onSaved={() => reload()}
onDeleted={() => reload()}
/>
)}
</div>
);
}