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
@@ -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>
);