From c30a82ec0f0ca69bedb9065e49ecb3b93276cb4a Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 14:08:21 +0200 Subject: [PATCH] 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) --- .../components/CloudForm.test.tsx | 135 +++++++++++++++--- .../nextcloud-status/components/CloudForm.tsx | 49 ++++--- .../cloud-form-errors.test.ts | 85 +++++++++++ .../nextcloud-status/cloud-form-errors.ts | 92 ++++++++++++ apps/web/src/lib/nextcloud-status-api.ts | 55 +++++-- apps/web/src/messages/de.json | 22 ++- apps/web/src/messages/en.json | 22 ++- apps/web/src/messages/umlaut-dictionary.ts | 2 + 8 files changed, 401 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts create mode 100644 apps/web/src/components/nextcloud-status/cloud-form-errors.ts diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx index ba54bbc..a6b03f1 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testi 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 { NextcloudFormError, type NextcloudInstance } from '@/lib/nextcloud-status-api'; import de from '@/messages/de.json'; import { CloudForm } from './CloudForm'; @@ -66,11 +66,41 @@ describe('CloudForm', () => { }); afterEach(() => cleanup()); - it('verlangt Kundenname und Adresse', async () => { + it('verlangt einen Kundennamen, ohne die API zu fragen', async () => { setup(); fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); 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(); }); @@ -110,23 +140,39 @@ describe('CloudForm', () => { 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); 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.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' })); + fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), { + target: { value: 'http://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: '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' })); await waitFor(() => expect(api.createInstance).toHaveBeenCalledWith({ @@ -147,6 +193,18 @@ describe('CloudForm', () => { 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([''], '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 () => { api.updateInstance.mockResolvedValue(EXISTING); const props = setup(EXISTING); @@ -185,18 +243,55 @@ describe('CloudForm', () => { 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( - 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(); - fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } }); - fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } }); + fillValid(); + 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' })); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Bitte geben Sie eine gültige Adresse', - ); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Bild hochladen'); + expect(alert).toHaveTextContent('nur intern erreichbar'); expect(props.onSaved).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.'); + }); }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx index b4f9ace..bf56b40 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx @@ -2,6 +2,11 @@ import { useTranslations } from 'next-intl'; import { useRef, useState } from 'react'; +import { + cloudFormErrorKey, + isAllowedLogoFileType, + validateCloudForm, +} from '@/components/nextcloud-status/cloud-form-errors'; import { createInstance, deleteInstance, @@ -38,8 +43,11 @@ function initialMode(instance: NextcloudInstance | null): LogoMode { * 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 …“). + * Typ an den Bytes) oder eine Bildadresse: https laedt der Browser selbst, ein + * 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) { const t = useTranslations('nextcloudStatus.form'); @@ -49,7 +57,8 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? ''); const [saving, setSaving] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); - const [error, setError] = useState(null); + // Uebersetzungsschluessel unter `nextcloudStatus.form` — nie ein Text vom Server. + const [errorKey, setErrorKey] = useState(null); const currentLogo = 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) => { 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); pendingFile.current = shrunk.then((picked) => { if (picked && picked.size > LOGO_MAX_BYTES) { - setError(t('logoFileTooLarge')); + setErrorKey('errors.logoFileTooLarge'); return null; } - setError(null); + setErrorKey(null); return 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')); + setErrorKey(null); + const invalid = validateCloudForm({ customerName, baseUrl, logoMode: mode, logoUrl }); + if (invalid) { + setErrorKey(`errors.${invalid}`); return; } + const name = customerName.trim(); + const url = baseUrl.trim(); setSaving(true); try { const upload = mode === 'upload' ? await pendingFile.current : null; @@ -106,7 +121,7 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr onSaved(saved); onClose(); } catch (err) { - setError(err instanceof Error && err.message ? err.message : t('saveError')); + setErrorKey(cloudFormErrorKey(err, 'saveError')); } finally { setSaving(false); } @@ -114,14 +129,14 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr const handleDelete = async () => { if (!instance) return; - setError(null); + setErrorKey(null); setSaving(true); try { await deleteInstance(instance.id); onDeleted(instance.id); onClose(); } catch (err) { - setError(err instanceof Error && err.message ? err.message : t('deleteError')); + setErrorKey(cloudFormErrorKey(err, 'deleteError')); setConfirmDelete(false); } finally { setSaving(false); @@ -147,12 +162,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr

{t('deleteConfirm', { name: instance.customerName })}

- {error && ( + {errorKey && (
- {error} + {t(errorKey)}
)}
@@ -267,12 +282,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr )} - {error && ( + {errorKey && (
- {error} + {t(errorKey)}
)} diff --git a/apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts b/apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts new file mode 100644 index 0000000..b2bbec2 --- /dev/null +++ b/apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts @@ -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.', () => { + 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'); + }); +}); diff --git a/apps/web/src/components/nextcloud-status/cloud-form-errors.ts b/apps/web/src/components/nextcloud-status/cloud-form-errors.ts new file mode 100644 index 0000000..58198e2 --- /dev/null +++ b/apps/web/src/components/nextcloud-status/cloud-form-errors.ts @@ -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.`, 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; +} diff --git a/apps/web/src/lib/nextcloud-status-api.ts b/apps/web/src/lib/nextcloud-status-api.ts index f372dbe..3a35ba6 100644 --- a/apps/web/src/lib/nextcloud-status-api.ts +++ b/apps/web/src/lib/nextcloud-status-api.ts @@ -97,16 +97,43 @@ export function logoSrc( 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 { +/** + * 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 { + let code: string | null = null; + let message = fallback; try { const body = await res.json(); - if (typeof body?.message === 'string') return body.message; - if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]); + if (typeof body?.code === 'string') code = body.code; + 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 { /* Antwort war kein JSON — Standardtext bleibt */ } - return fallback; + return new NextcloudFormError(res.status, code, message); } export interface NextcloudInstancePayload { @@ -128,7 +155,7 @@ export async function createInstance( credentials: 'include', 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(); } @@ -143,27 +170,27 @@ export async function updateInstance( credentials: 'include', 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(); } /** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */ export async function deleteInstance(id: string): Promise { 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. */ export async function checkAll(): Promise { 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(); } /** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */ export async function checkOne(id: string): Promise { 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(); } @@ -176,21 +203,21 @@ export async function uploadLogo(id: string, file: File): Promise { 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(); } /** POST /instances/:id/subscription — Glocke "Benachrichtigen" einschalten (jeder mit Modulzugriff). */ export async function subscribe(id: string): Promise { 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. */ @@ -199,7 +226,7 @@ export async function unsubscribe(id: string): Promise { method: 'DELETE', 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 ...). */ diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 05ad966..303dfec 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1960,11 +1960,10 @@ "logo": "Logo", "logoNone": "Kein Logo", "logoUpload": "Bild hochladen", - "logoUrl": "Bildadresse (https)", + "logoUrl": "Bildadresse", "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.", - "logoFileTooLarge": "Die Datei ist größer als 1 MB.", "logoCurrent": "Aktuelles Logo", "logoRemove": "Logo entfernen", "save": "Speichern", @@ -1973,10 +1972,23 @@ "delete": "Cloud entfernen", "deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?", "deleteConfirmYes": "Endgültig entfernen", - "required": "Bitte füllen Sie Kundenname und Adresse aus.", "saveError": "Die Cloud konnte nicht gespeichert 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": { "loading": "Status wird geladen …", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 4c563a6..bfbca3f 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1960,11 +1960,10 @@ "logo": "Logo", "logoNone": "No logo", "logoUpload": "Upload image", - "logoUrl": "Image address (https)", + "logoUrl": "Image address", "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.", - "logoFileTooLarge": "The file is larger than 1 MB.", "logoCurrent": "Current logo", "logoRemove": "Remove logo", "save": "Save", @@ -1973,10 +1972,23 @@ "delete": "Remove cloud", "deleteConfirm": "Do you really want to remove the cloud “{name}”?", "deleteConfirmYes": "Remove permanently", - "required": "Please fill in customer name and address.", "saveError": "The cloud could not be saved.", "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": { "loading": "Loading status …", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 06020c4..fb1a9ca 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -113,6 +113,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'aktuell', 'Statusseite', 'Bildadresse', + // quick-261008-j9f: Bildadresse per http (korrektes Deutsch) + 'Adressen', // quick-261002-kxc: Fehlerhinweise der Kachel (korrektes Deutsch) 'passt', 'vertrauenswürdig',