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
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CustomModuleRequestError,
checkCustomModuleUrl,
createCustomModule,
deleteCustomModule,
getCustomModule,
listCustomModules,
updateCustomModule,
} from './custom-modules-api';
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('checkCustomModuleUrl', () => {
it('nimmt eine https-Adresse an', () => {
expect(checkCustomModuleUrl('https://a.de')).toBe('ok');
});
it('lehnt http und Unparsbares als notHttps ab', () => {
expect(checkCustomModuleUrl('http://a.de')).toBe('notHttps');
expect(checkCustomModuleUrl('kaputt')).toBe('notHttps');
expect(checkCustomModuleUrl('javascript:alert(1)')).toBe('notHttps');
expect(checkCustomModuleUrl('')).toBe('notHttps');
});
it('erkennt Zugangsdaten in der Adresse', () => {
expect(checkCustomModuleUrl('https://u:p@a.de')).toBe('credentials');
expect(checkCustomModuleUrl('https://u@a.de')).toBe('credentials');
});
});
describe('custom-modules-api', () => {
it('listCustomModules ruft GET /custom-modules mit credentials include', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify([{ id: 'a' }]), { status: 200 }));
const list = await listCustomModules();
expect(list).toEqual([{ id: 'a' }]);
const [url, init] = mockFetch.mock.calls[0];
expect(String(url)).toMatch(/\/custom-modules$/);
expect(init.credentials).toBe('include');
});
it('getCustomModule liefert null bei 404', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 404 }));
await expect(getCustomModule('x')).resolves.toBeNull();
});
it('getCustomModule liefert die Zeile bei 200', async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({ id: 'x' }), { status: 200 }));
await expect(getCustomModule('x')).resolves.toEqual({ id: 'x' });
});
it('createCustomModule schickt POST mit JSON und wirft bei Fehler mit Servermeldung', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ message: ['Nur https'] }), { status: 400 }),
);
const input = { name: 'a', url: 'http://a.de', category: 'fleet' };
const err = await createCustomModule(input).catch((e) => e);
expect(err).toBeInstanceOf(CustomModuleRequestError);
expect(err.status).toBe(400);
expect(err.message).toBe('Nur https');
const [, init] = mockFetch.mock.calls[0];
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual(input);
});
it('updateCustomModule schickt PATCH, deleteCustomModule DELETE', async () => {
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
await updateCustomModule('x', { name: 'n' });
expect(mockFetch.mock.calls[0][1].method).toBe('PATCH');
expect(String(mockFetch.mock.calls[0][0])).toMatch(/\/custom-modules\/x$/);
await deleteCustomModule('x');
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
});
});
+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);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { resolvePageTitle } from './nav-store';
describe('resolvePageTitle — eigene Module', () => {
it('liefert den Namen des eigenen Moduls (slug = id)', () => {
const modules = [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }];
expect(resolvePageTitle('/modules/custom/abc', modules)).toEqual({ text: 'Wiki' });
});
it('findet eingebaute Module weiterhin ueber ihren slug', () => {
const modules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' },
];
expect(resolvePageTitle('/modules/domain-tools/domaincheck', modules)).toEqual({
text: 'Domaincheck',
});
});
});