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,16 @@
import { CustomModuleView } from '@/components/modules/custom-module-view';
interface CustomModulePageProps {
params: Promise<{ id: string }>;
}
/**
* Eigenes Modul (quick-260929-9wc): externe https-Seite im Rahmen.
* Bewusst OHNE ModuleAccessGate — eigene Module sind fuer alle angemeldeten
* Benutzer sichtbar (D-01); der statische Ordner `custom` hat im App Router
* Vorrang vor `[category]/[moduleSlug]`.
*/
export default async function CustomModulePage({ params }: CustomModulePageProps) {
const { id } = await params;
return <CustomModuleView id={id} />;
}
@@ -74,6 +74,20 @@ vi.mock('@/components/layout/app-version-badge', () => ({
AppVersionBadge: () => <div data-testid="app-version-badge" />,
}));
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
let mockCustomModules: {
id: string;
name: string;
url: string;
category: string;
createdAt: string;
updatedAt: string;
}[] = [];
vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
@@ -93,8 +107,21 @@ afterEach(() => {
vi.restoreAllMocks();
mockPathname = '/';
mockRefreshKey = 0;
mockCustomModules = [];
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
return {
id: 'cm-1',
name: 'Wiki',
url: 'https://wiki.example.com',
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
...overrides,
};
}
async function importSidebar() {
const mod = await import('./sidebar');
return mod.Sidebar;
@@ -243,6 +270,85 @@ describe('Sidebar', () => {
});
});
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
const link = screen.getByText('Wiki').closest('a');
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
const header = screen.getByText('infrastructure').closest('li');
expect(header?.contains(link)).toBe(true);
});
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
mockCustomModules = [customModule({ category: 'fleet' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('fleet')).toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
// Die eingebauten Kategorien bleiben daneben stehen.
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
});
const items = screen
.getByText('Domain-Tools')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
});
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
mockPathname = '/modules/custom/cm-1';
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
expect(
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).not.toBeNull();
expect(
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull();
});
it('die Suche findet ein eigenes Modul', async () => {
mockCustomModules = [customModule()];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
await waitFor(() => {
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
});
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
+106 -35
View File
@@ -13,6 +13,7 @@ import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() {
@@ -34,11 +35,50 @@ interface SidebarModule {
category: string;
}
/**
* Ein Eintrag der Seitenleiste: eingebautes Modul ODER eigenes Modul
* (quick-260929-9wc). Beide laufen durch dieselbe Gruppierung, Suche und
* Kachelliste; sie unterscheiden sich nur in Ziel und Kachelsymbol.
*/
interface SidebarEntry {
key: string;
name: string;
category: string;
href: string;
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
tileSlug: string;
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
extraActiveHref?: string;
}
function moduleEntry(mod: SidebarModule): SidebarEntry {
const category = mod.category || 'other';
return {
key: mod.id,
name: mod.name,
category,
href: `/modules/${category}/${mod.slug}`,
tileSlug: mod.slug,
extraActiveHref: `/modules/${mod.slug}`,
};
}
function customEntry(mod: CustomModule): SidebarEntry {
return {
key: `custom-${mod.id}`,
name: mod.name,
category: mod.category || 'other',
href: `/modules/custom/${mod.id}`,
tileSlug: 'custom',
};
}
export function Sidebar() {
const t = useTranslations('sidebar');
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [customModules, setCustomModules] = useState<CustomModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
// Kategorien sind anfangs aufgeklappt (Design „Mosaik“): die Modul-Kacheln
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
@@ -67,6 +107,9 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein.
@@ -74,19 +117,50 @@ export function Sidebar() {
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
const fetchActiveModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
const modules: SidebarModule[] = await res.json();
setActiveModules(modules);
setNavModules(modules);
const loadBuiltIn = async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
setActiveModules(await res.json());
}
} catch {
// silently fail
}
} catch {
// silently fail
}
}, [setNavModules]);
};
// Eigene Module (quick-260929-9wc) im selben Auffrisch-Takt; ein Fehler
// laesst die Leiste wie beim Modulabruf still beim bisherigen Stand.
const loadCustom = async () => {
try {
setCustomModules(await listCustomModules());
} catch {
// silently fail
}
};
await Promise.all([loadBuiltIn(), loadCustom()]);
}, []);
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
// Pfadsegment /modules/custom/<id> ohne Aenderung findet.
useEffect(() => {
setNavModules([
...activeModules,
...customModules.map((m) => ({
id: m.id,
slug: m.id,
name: m.name,
category: m.category,
})),
]);
}, [activeModules, customModules, setNavModules]);
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
const entries = useMemo<SidebarEntry[]>(
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
[activeModules, customModules],
);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
@@ -107,17 +181,16 @@ export function Sidebar() {
};
const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarModule[]>();
for (const mod of activeModules) {
const cat = mod.category || 'other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(mod);
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
if (!searchQuery) return Array.from(categories.entries());
const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = [];
const result: [string, SidebarEntry[]][] = [];
for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]);
@@ -127,7 +200,7 @@ export function Sidebar() {
}
}
return result;
}, [activeModules, searchQuery, categoryLabel]);
}, [entries, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
@@ -168,16 +241,15 @@ export function Sidebar() {
</ul>
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
{isCollapsed && activeModules.length > 0 && (
{isCollapsed && entries.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
{activeModules.map((mod) => {
const href = `/modules/${mod.category || 'other'}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
{entries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
<li key={entry.key}>
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
</Link>
</li>
);
@@ -192,7 +264,7 @@ export function Sidebar() {
</div>
<div className="mt-1" aria-label={t('categories.label')} role="group">
{activeModules.length === 0 ? (
{entries.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
@@ -202,7 +274,7 @@ export function Sidebar() {
</div>
) : (
<ul className="flex flex-col gap-2">
{filteredCategories.map(([category, mods]) => {
{filteredCategories.map(([category, categoryEntries]) => {
// Bei aktiver Suche immer aufgeklappt, sonst nach Wahl.
const open = searchQuery !== '' || !closedCategories.has(category);
return (
@@ -220,15 +292,14 @@ export function Sidebar() {
</button>
{open && (
<ul className="mt-0.5 flex flex-col gap-0.5">
{mods.map((mod) => {
const href = `/modules/${category}/${mod.slug}`;
const active = isActive(href) || isActive(`/modules/${mod.slug}`);
{categoryEntries.map((entry) => {
const active = isEntryActive(entry);
return (
<li key={mod.id}>
<Link href={href} className={moduleItemClass(active)}>
<li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />}
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{mod.name}</span>
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
<span className="truncate">{entry.name}</span>
</Link>
</li>
);
@@ -0,0 +1,96 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen',
embedHint: 'Manche Seiten lassen sich nicht einbetten.',
notFound: 'Dieses Modul gibt es nicht mehr.',
invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen',
};
return map[key] ?? key;
},
}));
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
vi.mock('@/lib/custom-modules-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
'@/lib/custom-modules-api',
);
return { ...actual, getCustomModule: mockGetCustomModule };
});
import { CustomModuleView } from './custom-module-view';
function mod(url: string) {
return {
id: 'abc',
name: 'Wiki',
url,
category: 'infrastructure',
createdAt: '2026-09-29T00:00:00.000Z',
updatedAt: '2026-09-29T00:00:00.000Z',
};
}
beforeEach(() => {
mockGetCustomModule.mockReset();
});
afterEach(() => {
cleanup();
});
describe('CustomModuleView', () => {
it('rendert den Rahmen mit Sandbox, ohne Referrer und ohne allow', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
const frame = await screen.findByTitle('Wiki');
expect(frame.tagName).toBe('IFRAME');
expect(frame).toHaveAttribute('src', 'https://example.com/wiki');
expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(frame.getAttribute('sandbox')).not.toContain('top-navigation');
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer');
expect(frame.getAttribute('allow')).toBe('');
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
});
it('zeigt Namen und immer sichtbar den Link „In neuem Tab öffnen“', async () => {
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
render(<CustomModuleView id="abc" />);
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
expect(screen.getByRole('heading', { name: 'Wiki' })).toBeInTheDocument();
});
it.each([
'http://example.com',
'javascript:alert(1)',
'data:text/html,x',
'https://user:pw@example.com',
])('rendert bei der Adresse %s weder Rahmen noch Link', async (url) => {
mockGetCustomModule.mockResolvedValue(mod(url));
const { container } = render(<CustomModuleView id="abc" />);
await screen.findByText('Ungültige Adresse');
expect(container.querySelector('iframe')).toBeNull();
expect(container.querySelector('a')).toBeNull();
});
it('zeigt bei 404 den Nicht-gefunden-Text', async () => {
mockGetCustomModule.mockResolvedValue(null);
const { container } = render(<CustomModuleView id="weg" />);
await waitFor(() => {
expect(screen.getByText('Dieses Modul gibt es nicht mehr.')).toBeInTheDocument();
});
expect(container.querySelector('iframe')).toBeNull();
});
});
@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
/**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): schmale
* Leiste mit Name, Hinweis und dem immer sichtbaren Knopf „In neuem Tab
* oeffnen“, darunter der flaechenfuellende Rahmen. Sandbox-Wert und
* Attribute wie im XFrame-Widget (`XFRAME_SANDBOX`, kein `allow`, kein
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
*/
export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules');
const [state, setState] = useState<LoadState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
setState({ status: 'loading' });
getCustomModule(id)
.then((mod) => {
if (cancelled) return;
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
})
.catch(() => {
if (!cancelled) setState({ status: 'missing' });
});
return () => {
cancelled = true;
};
}, [id]);
if (state.status === 'loading') {
return <div className="p-4 text-sm text-muted-foreground">{t('loading')}</div>;
}
if (state.status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
}
const { mod } = state;
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
return (
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground">{mod.name}</h1>
{urlOk && <p className="truncate text-xs text-muted-foreground">{t('embedHint')}</p>}
</div>
{urlOk && (
<a
href={mod.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary shrink-0"
>
{t('openInNewTab')}
</a>
)}
</div>
{urlOk ? (
<iframe
src={mod.url}
title={mod.name}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
className="w-full flex-1 rounded-lg border-0 bg-background"
data-testid="custom-module-frame"
/>
) : (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
{t('invalidUrl')}
</div>
)}
</div>
);
}
@@ -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',
});
});
});
+7
View File
@@ -1346,6 +1346,13 @@
"close": "Fenster schließen",
"contentLabel": "Änderungen"
},
"customModules": {
"openInNewTab": "In neuem Tab öffnen",
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
"notFound": "Dieses Modul gibt es nicht mehr.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …"
},
"moduleCategories": {
"domain-tools": "Domains",
"security-tools": "Sicherheit",
+7
View File
@@ -1346,6 +1346,13 @@
"close": "Close window",
"contentLabel": "Changes"
},
"customModules": {
"openInNewTab": "Open in new tab",
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
"notFound": "This module no longer exists.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …"
},
"moduleCategories": {
"domain-tools": "Domains",
"security-tools": "Security",
@@ -0,0 +1,22 @@
import { MODULE_CATEGORIES } from '@tessera/shared';
import { describe, expect, it } from 'vitest';
import de from './de.json';
import en from './en.json';
/**
* Gleichlauf der Modulkategorien (quick-260929-9wc): jede Kennung aus
* `MODULE_CATEGORIES` (Auswahl im Formular „Eigene Module“, Pruefung in der
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
* sonst zeigte die Seitenleiste die rohe Kennung.
*/
describe('MODULE_CATEGORIES', () => {
it.each([
['de', de],
['en', en],
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
const labels = messages.moduleCategories as Record<string, string>;
for (const category of MODULE_CATEGORIES) {
expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
}
});
});