{t('deleteConfirm', { name: instance.customerName })}
- {error && ( + {errorKey && (', () => {
+ 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',