feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
+117
View File
@@ -0,0 +1,117 @@
/**
* Eigene Module — API-Client (quick-260929-9wc). Konsumiert `/custom-modules`.
* Muster `favorites-api.ts`/`proxmox-api.ts`: `credentials: 'include'` fuer
* Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
import { isHttpsUrl } from '@/components/dashboard/widgets/xframe-config';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface CustomModule {
id: string;
name: string;
url: string;
category: string;
createdAt: string;
updatedAt: string;
}
export interface CustomModuleInput {
name: string;
url: string;
category: string;
}
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
export class CustomModuleRequestError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'CustomModuleRequestError';
}
}
/** Ergebnis der Adresspruefung im Web — dieselbe Regel wie die API. */
export type CustomModuleUrlCheck = 'ok' | 'notHttps' | 'credentials';
/**
* Gueltig ist nur eine https-Adresse ohne Zugangsdaten. Die https-Regel ist
* EINE im ganzen Web (`isHttpsUrl` aus `xframe-config.ts`); Zugangsdaten
* erkennt der URL-Parser an `username`/`password`.
*/
export function checkCustomModuleUrl(value: string): CustomModuleUrlCheck {
if (!isHttpsUrl(value)) return 'notHttps';
try {
const parsed = new URL(value);
if (parsed.username !== '' || parsed.password !== '') return 'credentials';
if (parsed.hostname === '') return 'notHttps';
} catch {
return 'notHttps';
}
return 'ok';
}
async function failure(res: Response): Promise<CustomModuleRequestError> {
let message = `Request failed (${res.status})`;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new CustomModuleRequestError(res.status, message);
}
export async function listCustomModules(): Promise<CustomModule[]> {
const res = await fetch(`${API_URL}/custom-modules`, { credentials: 'include' });
if (!res.ok) throw await failure(res);
return res.json();
}
/** `null` bei 404 (Eintrag geloescht oder fremd). */
export async function getCustomModule(id: string): Promise<CustomModule | null> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
credentials: 'include',
});
if (res.status === 404) return null;
if (!res.ok) throw await failure(res);
return res.json();
}
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
const res = await fetch(`${API_URL}/custom-modules`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw await failure(res);
return res.json();
}
export async function updateCustomModule(
id: string,
input: Partial<CustomModuleInput>,
): Promise<CustomModule> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw await failure(res);
return res.json();
}
export async function deleteCustomModule(id: string): Promise<void> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw await failure(res);
}