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