From de63b10749315e4830119f741ac7abb66d44a0f9 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 19 Jun 2026 13:38:25 +0200 Subject: [PATCH] feat(03-03): expanded module view with dynamic routing and API client - Create [moduleSlug] page that loads module component via MODULE_REGISTRY whitelist - Only registered slugs trigger dynamic imports; unknown slugs show not-found state (T-03-09) - Create api.ts with getActiveModules and getModuleBySlug utility functions - Back-link navigation from expanded view to category page --- .../modules/[category]/[moduleSlug]/page.tsx | 98 +++++++++++++++++++ apps/web/src/lib/api.ts | 67 +++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx create mode 100644 apps/web/src/lib/api.ts diff --git a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx new file mode 100644 index 0000000..702636e --- /dev/null +++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader'; +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +/** + * Expanded module view — renders the full module UI via lazy loading. + * + * Per D-05b: expanded/full view when user opens a specific module. + * Uses loadModuleComponent to get the dynamically imported component. + * + * Security (T-03-09): Only slugs whitelisted in MODULE_REGISTRY are loaded. + * Arbitrary slugs from URL params result in a not-found state — no + * arbitrary imports are ever triggered. + */ +export default function ExpandedModulePage() { + const params = useParams<{ category: string; moduleSlug: string }>(); + const { category, moduleSlug } = params; + const t = useTranslations('modules'); + + // Only load from the whitelist registry (T-03-09) + const isRegistered = moduleSlug in MODULE_REGISTRY; + const ModuleComponent = isRegistered + ? loadModuleComponent(moduleSlug) + : null; + + // Not-found state for unknown/unregistered slugs + if (!ModuleComponent) { + return ( +
+
+
+ + + + + +
+

{t('notFound')}

+

+ {t('notFoundDescription')} +

+ + {t('backToCategory')} + +
+
+ ); + } + + // Render the lazily loaded module component + return ( +
+ {/* Back navigation */} +
+ + + + + + {t('backToCategory')} + +
+ + {/* Module content — loaded on demand (MOD-04) */} + +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..1c98117 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,67 @@ +/** + * API client utilities for the Tessera frontend. + * + * Provides typed fetch wrappers for common API endpoints. + * All functions accept a cookie string for server-side auth forwarding. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +/** + * Module data shape from the API. + */ +export interface ApiModule { + id: string; + name: string; + slug: string; + category: string; + description: Record | string; + icon?: string; + version: string; + isSystem: boolean; +} + +/** + * Fetch all active modules for the current tenant. + * + * @param cookie - Cookie header value for auth forwarding + * @returns Array of active modules + */ +export async function getActiveModules(cookie: string): Promise { + const response = await fetch(`${API_URL}/modules/active`, { + headers: { Cookie: cookie }, + cache: 'no-store', + }); + + if (!response.ok) { + return []; + } + + return response.json(); +} + +/** + * Find a specific module by its slug. + * + * Fetches all modules and filters by slug. + * + * @param slug - Module slug (e.g., 'domaincheck') + * @param cookie - Cookie header value for auth forwarding + * @returns The module if found, or null + */ +export async function getModuleBySlug( + slug: string, + cookie: string, +): Promise { + const response = await fetch(`${API_URL}/modules`, { + headers: { Cookie: cookie }, + cache: 'no-store', + }); + + if (!response.ok) { + return null; + } + + const modules: ApiModule[] = await response.json(); + return modules.find((m) => m.slug === slug) ?? null; +}