Files
breadcrumb-the-shire/web/js/core/app-http.js
fs 6ac685084d Feature: Security Level mit Notiz für Domains
Implementiert ein Dialog-basiertes UI zum Bearbeiten des Security Levels
mit optionaler Notiz-Funktion für Helpdesk-Domains.

Backend:
- DomainSecurityLevelRepository: CRUD für security_levels mit note-Spalte
- DomainSecurityLevelService: Business-Logik mit Batch-Enrichment
- security-level-data() Endpoint: AJAX + Full-Page POST mit CSRF
- Migration 010: note TEXT-Spalte hinzugefügt
- Bugfix: findByTenantAndDomainNo gibt null bei leerem Array zurück

Frontend (Domain-Liste):
- Badge in Grid klickbar → öffnet Modal-Dialog
- Dialog mit Level-Select + Notiz-Textarea
- AJAX-Save mit Live-Badge-Update ohne Reload
- Event-Delegation für dynamische Grid-Inhalte

Frontend (Domain-Detail):
- Notiz-Feld im Security-Level-Formular
- PRG-Pattern mit Flash-Messages

Core:
- app-http.js: DEFAULT_HEADERS auf XMLHttpRequest für CSRF-Kompatibilität

i18n:
- Keys: Priority, Note, Optional note, Security level updated

Tests:
- DomainSecurityLevelServiceTest: CRUD + Batch-Enrichment
2026-04-21 13:43:16 +02:00

267 lines
6.4 KiB
JavaScript

import { telemetry } from './app-telemetry.js';
const JSON_ACCEPT = 'application/json';
const DEFAULT_HEADERS = Object.freeze({
'Accept': JSON_ACCEPT,
'X-Requested-With': 'XMLHttpRequest',
});
const toObject = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value;
};
const normalizeUrl = (value) => {
try {
return new URL(String(value || ''), window.location.href).toString();
} catch {
return String(value || '');
}
};
const normalizeRoute = (url) => {
try {
return new URL(url, window.location.href).pathname || '/';
} catch {
return '/';
}
};
const mergeHeaders = (baseHeaders, inputHeaders) => {
const headers = new Headers();
Object.entries(baseHeaders).forEach(([key, value]) => {
headers.set(key, value);
});
if (!inputHeaders) {
return headers;
}
if (inputHeaders instanceof Headers) {
inputHeaders.forEach((value, key) => headers.set(key, value));
return headers;
}
if (Array.isArray(inputHeaders)) {
inputHeaders.forEach((entry) => {
if (Array.isArray(entry) && entry.length >= 2) {
headers.set(String(entry[0]), String(entry[1]));
}
});
return headers;
}
if (typeof inputHeaders === 'object') {
Object.entries(inputHeaders).forEach(([key, value]) => {
if (value === null || value === undefined) {
return;
}
headers.set(String(key), String(value));
});
}
return headers;
};
const readJsonSafe = async (response) => {
const raw = await response.text();
if (raw.trim() === '') {
return null;
}
return JSON.parse(raw);
};
const messageFromPayload = (payload, fallback) => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
if (typeof payload.message === 'string' && payload.message.trim() !== '') {
return payload.message.trim();
}
if (typeof payload.error === 'string' && payload.error.trim() !== '') {
return payload.error.trim();
}
if (payload.errors && typeof payload.errors === 'object') {
const general = payload.errors.general;
if (typeof general === 'string' && general.trim() !== '') {
return general.trim();
}
}
return fallback;
};
const normalizeMethod = (value, fallback = 'GET') => {
const method = String(value || fallback).trim().toUpperCase();
return method === '' ? fallback : method;
};
const buildHttpErrorMessage = (method, status, message) => {
if (status > 0) {
return `[${method}] HTTP ${status}: ${message}`;
}
return `[${method}] ${message}`;
};
export class HttpError extends Error {
constructor(details = {}) {
const method = normalizeMethod(details.method || 'GET');
const status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
const message = String(details.message || 'Request failed').trim() || 'Request failed';
super(buildHttpErrorMessage(method, status, message));
this.name = 'HttpError';
this.status = status;
this.method = method;
this.url = normalizeUrl(details.url || '');
this.payload = details.payload ?? null;
this.cause = details.cause ?? null;
}
}
const emitHttpTelemetry = (error, source) => {
if (!(error instanceof HttpError)) {
return;
}
telemetry.capture('ajax_error', {
severity: error.status >= 500 || error.status === 0 ? 'error' : 'warning',
message: error.message,
meta: {
source,
module: 'app-http',
http_method: error.method,
http_status: error.status,
request_path: normalizeRoute(error.url),
error_code: error.status === 0 ? 'http_error_status_0' : `http_${error.status}`,
},
});
};
const toFormBody = (data) => {
if (data instanceof URLSearchParams) {
return data;
}
if (typeof FormData !== 'undefined' && data instanceof FormData) {
return new URLSearchParams(data);
}
if (data && typeof data === 'object') {
const body = new URLSearchParams();
Object.entries(data).forEach(([key, value]) => {
if (value === null || value === undefined) {
body.append(key, '');
} else {
body.append(key, String(value));
}
});
return body;
}
return new URLSearchParams();
};
const performRequest = async (url, options = {}) => {
const normalizedUrl = normalizeUrl(url);
const method = normalizeMethod(options.method || 'GET');
const headers = mergeHeaders(DEFAULT_HEADERS, options.headers || null);
const requestInit = {
...options,
method,
credentials: options.credentials || 'same-origin',
headers,
};
let response;
try {
response = await fetch(normalizedUrl, requestInit);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw error;
}
const httpError = new HttpError({
status: 0,
method,
url: normalizedUrl,
message: 'Network error',
payload: null,
cause: error,
});
emitHttpTelemetry(httpError, 'http.network');
throw httpError;
}
let payload = null;
try {
payload = await readJsonSafe(response);
} catch (error) {
const httpError = new HttpError({
status: 0,
method,
url: normalizedUrl,
message: 'Invalid JSON response',
payload: null,
cause: error,
});
emitHttpTelemetry(httpError, 'http.parse');
throw httpError;
}
if (!response.ok) {
const message = messageFromPayload(payload, response.statusText || 'Request failed');
const httpError = new HttpError({
status: response.status,
method,
url: normalizedUrl,
message,
payload,
});
emitHttpTelemetry(httpError, 'http.response');
throw httpError;
}
return payload;
};
export const getJson = (url, options = {}) => performRequest(url, {
...options,
method: 'GET',
});
export const postForm = (url, data, options = {}) => {
const body = toFormBody(data);
const headers = mergeHeaders({
...DEFAULT_HEADERS,
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}, options.headers || null);
return performRequest(url, {
...options,
method: 'POST',
headers,
body,
});
};
export const postJson = (url, payload, options = {}) => {
const headers = mergeHeaders({
...DEFAULT_HEADERS,
'Content-Type': 'application/json;charset=UTF-8',
}, options.headers || null);
return performRequest(url, {
...options,
method: 'POST',
headers,
body: JSON.stringify(payload ?? {}),
});
};