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:
@@ -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 />);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user