feat(nextcloud-status): Cloud-Formular zeigt nur deutsche Meldungen, Bildadresse auch per http (j9f)
- Vorabpruefung im Browser, API-Kennungen werden zu de/en-Texten - Beschriftung Bildadresse mit neuem Hinweis zu http und https Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testi
|
|||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
|
import { NextcloudFormError, type NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||||
import de from '@/messages/de.json';
|
import de from '@/messages/de.json';
|
||||||
import { CloudForm } from './CloudForm';
|
import { CloudForm } from './CloudForm';
|
||||||
|
|
||||||
@@ -66,11 +66,41 @@ describe('CloudForm', () => {
|
|||||||
});
|
});
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
it('verlangt Kundenname und Adresse', async () => {
|
it('verlangt einen Kundennamen, ohne die API zu fragen', async () => {
|
||||||
setup();
|
setup();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
'Bitte füllen Sie Kundenname und Adresse aus.',
|
'Bitte geben Sie einen Kundennamen ein.',
|
||||||
|
);
|
||||||
|
expect(api.createInstance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prüft die Adresse der Cloud vorab auf Deutsch', async () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
|
target: { value: 'cloud.example.de' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.',
|
||||||
|
);
|
||||||
|
expect(api.createInstance).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prüft die Bildadresse vorab auf Deutsch', async () => {
|
||||||
|
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', { selector: 'input[type="radio"]' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||||
|
target: { value: 'ftp://x' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Bitte geben Sie eine gültige Bildadresse mit http:// oder https:// ein.',
|
||||||
);
|
);
|
||||||
expect(api.createInstance).not.toHaveBeenCalled();
|
expect(api.createInstance).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -110,23 +140,39 @@ describe('CloudForm', () => {
|
|||||||
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
|
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
|
it('sendet eine http-Bildadresse unverändert an die API', async () => {
|
||||||
api.createInstance.mockResolvedValue(EXISTING);
|
api.createInstance.mockResolvedValue(EXISTING);
|
||||||
setup();
|
setup();
|
||||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
target: { value: 'https://cloud.neu.de' },
|
target: { value: 'https://cloud.neu.de' },
|
||||||
});
|
});
|
||||||
fireEvent.click(
|
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
|
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||||
);
|
target: { value: 'http://logo.example.de/a.png' },
|
||||||
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
|
});
|
||||||
fireEvent.change(
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
|
await waitFor(() =>
|
||||||
{
|
expect(api.createInstance).toHaveBeenCalledWith({
|
||||||
target: { value: 'https://logo.example.de/a.png' },
|
customerName: 'Neu',
|
||||||
},
|
baseUrl: 'https://cloud.neu.de',
|
||||||
|
logoUrl: 'http://logo.example.de/a.png',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet eine https-Bildadresse und erklärt, wie Bilder geholt werden', 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', { selector: 'input[type="radio"]' }));
|
||||||
|
expect(screen.getByText(/holt Tessera beim Speichern einmalig ab/)).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||||
|
target: { value: 'https://logo.example.de/a.png' },
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.createInstance).toHaveBeenCalledWith({
|
expect(api.createInstance).toHaveBeenCalledWith({
|
||||||
@@ -147,6 +193,18 @@ describe('CloudForm', () => {
|
|||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Datei mit nicht erlaubtem Typ sofort ab', async () => {
|
||||||
|
setup();
|
||||||
|
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||||
|
const svg = new File(['<svg/>'], 'logo.svg', { type: 'image/svg+xml' });
|
||||||
|
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||||
|
target: { files: [svg] },
|
||||||
|
});
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ändert eine vorhandene Cloud', async () => {
|
it('ändert eine vorhandene Cloud', async () => {
|
||||||
api.updateInstance.mockResolvedValue(EXISTING);
|
api.updateInstance.mockResolvedValue(EXISTING);
|
||||||
const props = setup(EXISTING);
|
const props = setup(EXISTING);
|
||||||
@@ -185,18 +243,55 @@ describe('CloudForm', () => {
|
|||||||
expect(props.onDeleted).toHaveBeenCalledWith('i1');
|
expect(props.onDeleted).toHaveBeenCalledWith('i1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zeigt die Fehlermeldung der API im Formular', async () => {
|
function fillValid() {
|
||||||
|
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||||
|
target: { value: 'https://cloud.neu.de' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('übersetzt eine Fehlerkennung der API in den deutschen Text', async () => {
|
||||||
api.createInstance.mockRejectedValue(
|
api.createInstance.mockRejectedValue(
|
||||||
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
|
new NextcloudFormError(400, 'logoFetchInternal', 'Diese Bildadresse ist nur intern …'),
|
||||||
);
|
);
|
||||||
const props = setup();
|
const props = setup();
|
||||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
fillValid();
|
||||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
|
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||||
|
target: { value: 'http://192.168.1.5/logo.png' },
|
||||||
|
});
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
const alert = await screen.findByRole('alert');
|
||||||
'Bitte geben Sie eine gültige Adresse',
|
expect(alert).toHaveTextContent('Bild hochladen');
|
||||||
);
|
expect(alert).toHaveTextContent('nur intern erreichbar');
|
||||||
expect(props.onSaved).not.toHaveBeenCalled();
|
expect(props.onSaved).not.toHaveBeenCalled();
|
||||||
expect(props.onClose).not.toHaveBeenCalled();
|
expect(props.onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zeigt bei englischem Servertext ohne Kennung nur den allgemeinen deutschen Text', async () => {
|
||||||
|
api.createInstance.mockRejectedValue(
|
||||||
|
new NextcloudFormError(400, null, 'logoUrl must be a URL address'),
|
||||||
|
);
|
||||||
|
setup();
|
||||||
|
fillValid();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Die Cloud konnte nicht gespeichert werden.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/must be a URL address/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('413 beim Hochladen zeigt den Text zur Dateigröße', async () => {
|
||||||
|
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||||
|
api.uploadLogo.mockRejectedValue(new NextcloudFormError(413, null, 'File too large'));
|
||||||
|
setup();
|
||||||
|
fillValid();
|
||||||
|
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' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
cloudFormErrorKey,
|
||||||
|
isAllowedLogoFileType,
|
||||||
|
validateCloudForm,
|
||||||
|
} from '@/components/nextcloud-status/cloud-form-errors';
|
||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
@@ -38,8 +43,11 @@ function initialMode(instance: NextcloudInstance | null): LogoMode {
|
|||||||
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
|
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
|
||||||
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
|
* 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
|
* 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.
|
* Typ an den Bytes) oder eine Bildadresse: https laedt der Browser selbst, ein
|
||||||
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
|
* http-Bild holt Tessera beim Speichern einmalig ab und speichert es wie einen
|
||||||
|
* Upload (quick-261008-j9f). Anlegen wartet auf die erste Pruefung („Wird
|
||||||
|
* geprueft …“). Fehler werden nie als Servertext gezeigt: das Formular prueft
|
||||||
|
* vorab, und Fehler der API werden ueber ihre Kennung uebersetzt (D-05).
|
||||||
*/
|
*/
|
||||||
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
|
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
|
||||||
const t = useTranslations('nextcloudStatus.form');
|
const t = useTranslations('nextcloudStatus.form');
|
||||||
@@ -49,7 +57,8 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
// Uebersetzungsschluessel unter `nextcloudStatus.form` — nie ein Text vom Server.
|
||||||
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentLogo =
|
const currentLogo =
|
||||||
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
||||||
@@ -61,26 +70,32 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
|
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const original = e.target.files?.[0] ?? null;
|
const original = e.target.files?.[0] ?? null;
|
||||||
|
if (original && !isAllowedLogoFileType(original.type)) {
|
||||||
|
pendingFile.current = Promise.resolve(null);
|
||||||
|
setErrorKey('errors.logoFileInvalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shrunk = original ? shrinkImage(original) : Promise.resolve(null);
|
const shrunk = original ? shrinkImage(original) : Promise.resolve(null);
|
||||||
pendingFile.current = shrunk.then((picked) => {
|
pendingFile.current = shrunk.then((picked) => {
|
||||||
if (picked && picked.size > LOGO_MAX_BYTES) {
|
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||||
setError(t('logoFileTooLarge'));
|
setErrorKey('errors.logoFileTooLarge');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
setError(null);
|
setErrorKey(null);
|
||||||
return picked;
|
return picked;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setErrorKey(null);
|
||||||
const name = customerName.trim();
|
const invalid = validateCloudForm({ customerName, baseUrl, logoMode: mode, logoUrl });
|
||||||
const url = baseUrl.trim();
|
if (invalid) {
|
||||||
if (!name || !url) {
|
setErrorKey(`errors.${invalid}`);
|
||||||
setError(t('required'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const name = customerName.trim();
|
||||||
|
const url = baseUrl.trim();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const upload = mode === 'upload' ? await pendingFile.current : null;
|
const upload = mode === 'upload' ? await pendingFile.current : null;
|
||||||
@@ -106,7 +121,7 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
onSaved(saved);
|
onSaved(saved);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error && err.message ? err.message : t('saveError'));
|
setErrorKey(cloudFormErrorKey(err, 'saveError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -114,14 +129,14 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
setError(null);
|
setErrorKey(null);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await deleteInstance(instance.id);
|
await deleteInstance(instance.id);
|
||||||
onDeleted(instance.id);
|
onDeleted(instance.id);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
|
setErrorKey(cloudFormErrorKey(err, 'deleteError'));
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -147,12 +162,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
<p className="text-sm text-foreground">
|
<p className="text-sm text-foreground">
|
||||||
{t('deleteConfirm', { name: instance.customerName })}
|
{t('deleteConfirm', { name: instance.customerName })}
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{errorKey && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{error}
|
{t(errorKey)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
@@ -267,12 +282,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
|||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{error && (
|
{errorKey && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
>
|
>
|
||||||
{error}
|
{t(errorKey)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { NextcloudFormError } from '@/lib/nextcloud-status-api';
|
||||||
|
import {
|
||||||
|
CLOUD_FORM_ERROR_CODES,
|
||||||
|
cloudFormErrorKey,
|
||||||
|
isAllowedLogoFileType,
|
||||||
|
validateCloudForm,
|
||||||
|
} from './cloud-form-errors';
|
||||||
|
|
||||||
|
const OK = {
|
||||||
|
customerName: 'Kunde',
|
||||||
|
baseUrl: 'https://cloud.example.de',
|
||||||
|
logoMode: 'none' as const,
|
||||||
|
logoUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateCloudForm', () => {
|
||||||
|
it('gueltige Eingaben -> null', () => {
|
||||||
|
expect(validateCloudForm(OK)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Name: leer und zu lang', () => {
|
||||||
|
expect(validateCloudForm({ ...OK, customerName: ' ' })).toBe('customerNameRequired');
|
||||||
|
expect(validateCloudForm({ ...OK, customerName: 'x'.repeat(121) })).toBe('customerNameTooLong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Adresse: leer, ohne Schema, falsches Schema', () => {
|
||||||
|
expect(validateCloudForm({ ...OK, baseUrl: '' })).toBe('baseUrlRequired');
|
||||||
|
expect(validateCloudForm({ ...OK, baseUrl: 'cloud.example.de' })).toBe('baseUrlInvalid');
|
||||||
|
expect(validateCloudForm({ ...OK, baseUrl: 'ftp://x' })).toBe('baseUrlInvalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bildadresse nur im Modus url geprueft', () => {
|
||||||
|
for (const logoUrl of ['ftp://x', 'kein link', 'http://u:p@a.de/x.png']) {
|
||||||
|
expect(validateCloudForm({ ...OK, logoMode: 'url', logoUrl })).toBe('logoUrlInvalid');
|
||||||
|
}
|
||||||
|
for (const logoUrl of ['http://logo.example.de/a.png', 'https://logo.example.de/a.png', '']) {
|
||||||
|
expect(validateCloudForm({ ...OK, logoMode: 'url', logoUrl })).toBeNull();
|
||||||
|
}
|
||||||
|
expect(validateCloudForm({ ...OK, logoMode: 'none', logoUrl: 'ftp://x' })).toBeNull();
|
||||||
|
expect(validateCloudForm({ ...OK, logoMode: 'upload', logoUrl: 'ftp://x' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAllowedLogoFileType', () => {
|
||||||
|
it('nur PNG, JPEG, GIF, WebP', () => {
|
||||||
|
for (const t of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) {
|
||||||
|
expect(isAllowedLogoFileType(t)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isAllowedLogoFileType('image/svg+xml')).toBe(false);
|
||||||
|
expect(isAllowedLogoFileType('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cloudFormErrorKey', () => {
|
||||||
|
it('bekannte Kennungen -> errors.<code>', () => {
|
||||||
|
for (const code of CLOUD_FORM_ERROR_CODES) {
|
||||||
|
expect(cloudFormErrorKey(new NextcloudFormError(400, code, 'x'), 'saveError')).toBe(
|
||||||
|
`errors.${code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('413 und 404 ohne Kennung', () => {
|
||||||
|
expect(
|
||||||
|
cloudFormErrorKey(new NextcloudFormError(413, null, 'File too large'), 'saveError'),
|
||||||
|
).toBe('errors.logoFileTooLarge');
|
||||||
|
expect(cloudFormErrorKey(new NextcloudFormError(404, null, 'Not Found'), 'deleteError')).toBe(
|
||||||
|
'errors.notFound',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannte Kennung, englischer Text oder einfacher Fehler -> allgemeiner Text', () => {
|
||||||
|
expect(cloudFormErrorKey(new NextcloudFormError(400, 'whatever', 'x'), 'saveError')).toBe(
|
||||||
|
'saveError',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
cloudFormErrorKey(
|
||||||
|
new NextcloudFormError(400, null, 'logoUrl must be a URL address'),
|
||||||
|
'saveError',
|
||||||
|
),
|
||||||
|
).toBe('saveError');
|
||||||
|
expect(cloudFormErrorKey(new Error('boom'), 'deleteError')).toBe('deleteError');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextcloudFormError } from '@/lib/nextcloud-status-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlertexte des Cloud-Formulars (quick-261008-j9f, D-05): vorab pruefen und
|
||||||
|
* Fehlerkennungen der API in Uebersetzungsschluessel abbilden. Die Oberflaeche
|
||||||
|
* zeigt nie den Text des Servers. Die Kennungen entsprechen dem Katalog in
|
||||||
|
* `apps/api/src/nextcloud-status/nextcloud-form-errors.ts`.
|
||||||
|
*/
|
||||||
|
export const CLOUD_FORM_ERROR_CODES = [
|
||||||
|
'customerNameRequired',
|
||||||
|
'customerNameTooLong',
|
||||||
|
'baseUrlRequired',
|
||||||
|
'baseUrlInvalid',
|
||||||
|
'logoUrlInvalid',
|
||||||
|
'logoFileInvalid',
|
||||||
|
'logoFileTooLarge',
|
||||||
|
'logoFetchInternal',
|
||||||
|
'logoFetchUnreachable',
|
||||||
|
'logoFetchNotImage',
|
||||||
|
'logoFetchTooLarge',
|
||||||
|
'notFound',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CloudFormErrorCode = (typeof CLOUD_FORM_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
const MAX_NAME = 120;
|
||||||
|
const MAX_URL = 2048;
|
||||||
|
|
||||||
|
/** http(s)-Adresse ohne Zugangsdaten und mit Rechnername. */
|
||||||
|
function isHttpUrl(raw: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||||
|
!url.username &&
|
||||||
|
!url.password &&
|
||||||
|
url.hostname !== ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudFormInput {
|
||||||
|
customerName: string;
|
||||||
|
baseUrl: string;
|
||||||
|
logoMode: 'none' | 'upload' | 'url';
|
||||||
|
logoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erster Fehler in der Reihenfolge Name, Adresse, Bildadresse — oder `null`. */
|
||||||
|
export function validateCloudForm(input: CloudFormInput): CloudFormErrorCode | null {
|
||||||
|
const name = input.customerName.trim();
|
||||||
|
if (!name) return 'customerNameRequired';
|
||||||
|
if (name.length > MAX_NAME) return 'customerNameTooLong';
|
||||||
|
|
||||||
|
const base = input.baseUrl.trim();
|
||||||
|
if (!base) return 'baseUrlRequired';
|
||||||
|
if (base.length > MAX_URL || !isHttpUrl(base)) return 'baseUrlInvalid';
|
||||||
|
|
||||||
|
if (input.logoMode === 'url') {
|
||||||
|
const logo = input.logoUrl.trim();
|
||||||
|
if (logo && (logo.length > MAX_URL || !isHttpUrl(logo))) return 'logoUrlInvalid';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
|
||||||
|
/** Dateitypen, die die API als Logo annimmt (SVG nie). */
|
||||||
|
export function isAllowedLogoFileType(type: string): boolean {
|
||||||
|
return ALLOWED_LOGO_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownCode(code: string | null): code is CloudFormErrorCode {
|
||||||
|
return code !== null && (CLOUD_FORM_ERROR_CODES as readonly string[]).includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersetzungsschluessel (unter `nextcloudStatus.form`) zu einem Fehler:
|
||||||
|
* bekannte Kennung -> `errors.<code>`, 413 -> Dateigroesse, 404 -> nicht
|
||||||
|
* gefunden, sonst der allgemeine Text (`saveError` / `deleteError`).
|
||||||
|
*/
|
||||||
|
export function cloudFormErrorKey(err: unknown, fallback: 'saveError' | 'deleteError'): string {
|
||||||
|
if (err instanceof NextcloudFormError) {
|
||||||
|
if (isKnownCode(err.code)) return `errors.${err.code}`;
|
||||||
|
if (err.status === 413) return 'errors.logoFileTooLarge';
|
||||||
|
if (err.status === 404) return 'errors.notFound';
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -97,16 +97,43 @@ export function logoSrc(
|
|||||||
return instance.logoUrl ?? null;
|
return instance.logoUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
|
/**
|
||||||
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
|
* Fehler der Cloud-Routen mit Statuscode und stabiler Kennung (quick-261008-j9f,
|
||||||
|
* D-05). `code` ist `body.code` der API (z. B. `logoFetchInternal`) oder, bei
|
||||||
|
* Meldungen der globalen Eingabepruefung, die Kennung als Meldungstext; sonst
|
||||||
|
* `null`. Die Oberflaeche zeigt nie `message`, sondern uebersetzt die Kennung
|
||||||
|
* (siehe `cloud-form-errors.ts`); `message` bleibt fuer andere Aufrufer lesbar.
|
||||||
|
*/
|
||||||
|
export class NextcloudFormError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly code: string | null;
|
||||||
|
|
||||||
|
constructor(status: number, code: string | null, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'NextcloudFormError';
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest Kennung und Meldung aus dem Antwortkoerper; ohne lesbaren Koerper gilt der Standardtext. */
|
||||||
|
async function readError(res: Response, fallback: string): Promise<NextcloudFormError> {
|
||||||
|
let code: string | null = null;
|
||||||
|
let message = fallback;
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (typeof body?.message === 'string') return body.message;
|
if (typeof body?.code === 'string') code = body.code;
|
||||||
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
|
if (typeof body?.message === 'string') {
|
||||||
|
message = body.message;
|
||||||
|
} else if (Array.isArray(body?.message) && body.message.length > 0) {
|
||||||
|
message = String(body.message[0]);
|
||||||
|
// Meldungen der Eingabepruefung sind Kennungen (nur Buchstaben, klein beginnend).
|
||||||
|
if (code === null && /^[a-z][A-Za-z]*$/.test(message)) code = message;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* Antwort war kein JSON — Standardtext bleibt */
|
/* Antwort war kein JSON — Standardtext bleibt */
|
||||||
}
|
}
|
||||||
return fallback;
|
return new NextcloudFormError(res.status, code, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextcloudInstancePayload {
|
export interface NextcloudInstancePayload {
|
||||||
@@ -128,7 +155,7 @@ export async function createInstance(
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance'));
|
if (!res.ok) throw await readError(res, 'Failed to create nextcloud instance');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,27 +170,27 @@ export async function updateInstance(
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance'));
|
if (!res.ok) throw await readError(res, 'Failed to update nextcloud instance');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
|
/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
|
||||||
export async function deleteInstance(id: string): Promise<void> {
|
export async function deleteInstance(id: string): Promise<void> {
|
||||||
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
|
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance'));
|
if (!res.ok) throw await readError(res, 'Failed to delete nextcloud instance');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
|
/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
|
||||||
export async function checkAll(): Promise<NextcloudList> {
|
export async function checkAll(): Promise<NextcloudList> {
|
||||||
const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
|
const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances'));
|
if (!res.ok) throw await readError(res, 'Failed to check nextcloud instances');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
|
/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
|
||||||
export async function checkOne(id: string): Promise<NextcloudInstance> {
|
export async function checkOne(id: string): Promise<NextcloudInstance> {
|
||||||
const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
|
const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance'));
|
if (!res.ok) throw await readError(res, 'Failed to check nextcloud instance');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,21 +203,21 @@ export async function uploadLogo(id: string, file: File): Promise<NextcloudInsta
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo'));
|
if (!res.ok) throw await readError(res, 'Failed to upload logo');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
|
/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
|
||||||
export async function removeLogo(id: string): Promise<NextcloudInstance> {
|
export async function removeLogo(id: string): Promise<NextcloudInstance> {
|
||||||
const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
|
const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
|
if (!res.ok) throw await readError(res, 'Failed to remove logo');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /instances/:id/subscription — Glocke "Benachrichtigen" einschalten (jeder mit Modulzugriff). */
|
/** POST /instances/:id/subscription — Glocke "Benachrichtigen" einschalten (jeder mit Modulzugriff). */
|
||||||
export async function subscribe(id: string): Promise<void> {
|
export async function subscribe(id: string): Promise<void> {
|
||||||
const res = await fetch(`${BASE}/${id}/subscription`, { method: 'POST', credentials: 'include' });
|
const res = await fetch(`${BASE}/${id}/subscription`, { method: 'POST', credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to subscribe'));
|
if (!res.ok) throw await readError(res, 'Failed to subscribe');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /instances/:id/subscription — Glocke ausschalten. */
|
/** DELETE /instances/:id/subscription — Glocke ausschalten. */
|
||||||
@@ -199,7 +226,7 @@ export async function unsubscribe(id: string): Promise<void> {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to unsubscribe'));
|
if (!res.ok) throw await readError(res, 'Failed to unsubscribe');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein abgewiesener Aufruf der Meldungsabfrage; `status` ist der HTTP-Status (401, 403 ...). */
|
/** Ein abgewiesener Aufruf der Meldungsabfrage; `status` ist der HTTP-Status (401, 403 ...). */
|
||||||
|
|||||||
@@ -1960,11 +1960,10 @@
|
|||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
"logoNone": "Kein Logo",
|
"logoNone": "Kein Logo",
|
||||||
"logoUpload": "Bild hochladen",
|
"logoUpload": "Bild hochladen",
|
||||||
"logoUrl": "Bildadresse (https)",
|
"logoUrl": "Bildadresse",
|
||||||
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||||
"logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.",
|
"logoUrlHint": "Adressen mit https:// lädt Ihr Browser direkt. Ein Bild unter einer http://-Adresse holt Tessera beim Speichern einmalig ab und speichert es wie ein hochgeladenes Bild. Bilder, die nur intern erreichbar sind, laden Sie bitte über „Bild hochladen“ hoch.",
|
||||||
"logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
|
"logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
|
||||||
"logoFileTooLarge": "Die Datei ist größer als 1 MB.",
|
|
||||||
"logoCurrent": "Aktuelles Logo",
|
"logoCurrent": "Aktuelles Logo",
|
||||||
"logoRemove": "Logo entfernen",
|
"logoRemove": "Logo entfernen",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
@@ -1973,10 +1972,23 @@
|
|||||||
"delete": "Cloud entfernen",
|
"delete": "Cloud entfernen",
|
||||||
"deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
|
"deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
|
||||||
"deleteConfirmYes": "Endgültig entfernen",
|
"deleteConfirmYes": "Endgültig entfernen",
|
||||||
"required": "Bitte füllen Sie Kundenname und Adresse aus.",
|
|
||||||
"saveError": "Die Cloud konnte nicht gespeichert werden.",
|
"saveError": "Die Cloud konnte nicht gespeichert werden.",
|
||||||
"deleteError": "Die Cloud konnte nicht entfernt werden.",
|
"deleteError": "Die Cloud konnte nicht entfernt werden.",
|
||||||
"close": "Dialog schließen"
|
"close": "Dialog schließen",
|
||||||
|
"errors": {
|
||||||
|
"customerNameRequired": "Bitte geben Sie einen Kundennamen ein.",
|
||||||
|
"customerNameTooLong": "Der Kundenname darf höchstens 120 Zeichen lang sein.",
|
||||||
|
"baseUrlRequired": "Bitte geben Sie die Adresse der Cloud ein.",
|
||||||
|
"baseUrlInvalid": "Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.",
|
||||||
|
"logoUrlInvalid": "Bitte geben Sie eine gültige Bildadresse mit http:// oder https:// ein.",
|
||||||
|
"logoFileInvalid": "Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.",
|
||||||
|
"logoFileTooLarge": "Die Datei ist größer als 1 MB.",
|
||||||
|
"logoFetchInternal": "Diese Bildadresse ist nur intern erreichbar. Tessera holt nur Bilder aus dem Internet ab. Bitte laden Sie das Bild über „Bild hochladen“ hoch.",
|
||||||
|
"logoFetchUnreachable": "Das Bild konnte unter dieser Adresse nicht abgerufen werden. Bitte prüfen Sie die Adresse oder laden Sie das Bild über „Bild hochladen“ hoch.",
|
||||||
|
"logoFetchNotImage": "Unter dieser Adresse liegt kein Bild im Format PNG, JPEG, GIF oder WebP.",
|
||||||
|
"logoFetchTooLarge": "Das Bild unter dieser Adresse ist größer als 1 MB.",
|
||||||
|
"notFound": "Diese Cloud gibt es nicht mehr. Bitte laden Sie die Seite neu."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"loading": "Status wird geladen …",
|
"loading": "Status wird geladen …",
|
||||||
|
|||||||
@@ -1960,11 +1960,10 @@
|
|||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
"logoNone": "No logo",
|
"logoNone": "No logo",
|
||||||
"logoUpload": "Upload image",
|
"logoUpload": "Upload image",
|
||||||
"logoUrl": "Image address (https)",
|
"logoUrl": "Image address",
|
||||||
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
"logoUrlPlaceholder": "https://www.example.com/logo.png",
|
||||||
"logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.",
|
"logoUrlHint": "Addresses starting with https:// are loaded directly by your browser. An image at an http:// address is fetched once by Tessera when you save and stored like an uploaded image. For images that can only be reached internally, please use “Upload image”.",
|
||||||
"logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
|
"logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
|
||||||
"logoFileTooLarge": "The file is larger than 1 MB.",
|
|
||||||
"logoCurrent": "Current logo",
|
"logoCurrent": "Current logo",
|
||||||
"logoRemove": "Remove logo",
|
"logoRemove": "Remove logo",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -1973,10 +1972,23 @@
|
|||||||
"delete": "Remove cloud",
|
"delete": "Remove cloud",
|
||||||
"deleteConfirm": "Do you really want to remove the cloud “{name}”?",
|
"deleteConfirm": "Do you really want to remove the cloud “{name}”?",
|
||||||
"deleteConfirmYes": "Remove permanently",
|
"deleteConfirmYes": "Remove permanently",
|
||||||
"required": "Please fill in customer name and address.",
|
|
||||||
"saveError": "The cloud could not be saved.",
|
"saveError": "The cloud could not be saved.",
|
||||||
"deleteError": "The cloud could not be removed.",
|
"deleteError": "The cloud could not be removed.",
|
||||||
"close": "Close dialog"
|
"close": "Close dialog",
|
||||||
|
"errors": {
|
||||||
|
"customerNameRequired": "Please enter a customer name.",
|
||||||
|
"customerNameTooLong": "The customer name may be at most 120 characters long.",
|
||||||
|
"baseUrlRequired": "Please enter the address of the cloud.",
|
||||||
|
"baseUrlInvalid": "Please enter a valid address starting with http:// or https://.",
|
||||||
|
"logoUrlInvalid": "Please enter a valid image address starting with http:// or https://.",
|
||||||
|
"logoFileInvalid": "Please upload a PNG, JPEG, GIF or WebP image of at most 1 MB.",
|
||||||
|
"logoFileTooLarge": "The file is larger than 1 MB.",
|
||||||
|
"logoFetchInternal": "This image address can only be reached internally. Tessera only fetches images from the internet. Please use “Upload image” instead.",
|
||||||
|
"logoFetchUnreachable": "The image could not be retrieved from this address. Please check the address or use “Upload image”.",
|
||||||
|
"logoFetchNotImage": "There is no PNG, JPEG, GIF or WebP image at this address.",
|
||||||
|
"logoFetchTooLarge": "The image at this address is larger than 1 MB.",
|
||||||
|
"notFound": "This cloud no longer exists. Please reload the page."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"widget": {
|
"widget": {
|
||||||
"loading": "Loading status …",
|
"loading": "Loading status …",
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'aktuell',
|
'aktuell',
|
||||||
'Statusseite',
|
'Statusseite',
|
||||||
'Bildadresse',
|
'Bildadresse',
|
||||||
|
// quick-261008-j9f: Bildadresse per http (korrektes Deutsch)
|
||||||
|
'Adressen',
|
||||||
// quick-261002-kxc: Fehlerhinweise der Kachel (korrektes Deutsch)
|
// quick-261002-kxc: Fehlerhinweise der Kachel (korrektes Deutsch)
|
||||||
'passt',
|
'passt',
|
||||||
'vertrauenswürdig',
|
'vertrauenswürdig',
|
||||||
|
|||||||
Reference in New Issue
Block a user