docs(04): create phase plan — marketplace & portal navigation (4 plans, 3 waves)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:17:02 +02:00
parent 670fe088a4
commit 0470d500b2
5 changed files with 770 additions and 2 deletions
+16 -2
View File
@@ -130,7 +130,21 @@ Decimal phases appear between their surrounding integers in numeric order.
4. User can select a module from the sidebar and it opens in the main content area 4. User can select a module from the sidebar and it opens in the main content area
5. User can search and filter modules in the sidebar 5. User can search and filter modules in the sidebar
**Plans**: TBD **Plans**: 4 plans
**Wave 1**
- [ ] 04-01-PLAN.md -- Test infra (Vitest) + marketplace i18n + Marketplace page slice: card grid browse + activate (MRKT-01/02/04)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 04-02-PLAN.md -- Marketplace refinements: search/category/status filters, deactivation dialog, toast, Super-Admin tenant context, module detail page
- [ ] 04-03-PLAN.md -- Sidebar enhancement: Next.js Link + usePathname active state, per-module links, sidebar search, live refresh (PRTAL-02/03/05, MRKT-03)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 04-04-PLAN.md -- Visual Verification: Human confirms marketplace, activation, tenant context, and sidebar navigation
**UI hint**: yes **UI hint**: yes
### Phase 5: Dashboard & Calendar ### Phase 5: Dashboard & Calendar
@@ -174,6 +188,6 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6
| 1. Foundation & Portal Shell | 2/3 | In Progress | - | | 1. Foundation & Portal Shell | 2/3 | In Progress | - |
| 2. Authentication & Multi-Tenancy | 2/5 | In Progress| | | 2. Authentication & Multi-Tenancy | 2/5 | In Progress| |
| 3. Module System & Domaincheck | 3/4 | In Progress| | | 3. Module System & Domaincheck | 3/4 | In Progress| |
| 4. Marketplace & Portal Navigation | 0/TBD | Not started | - | | 4. Marketplace & Portal Navigation | 0/4 | Not started | - |
| 5. Dashboard & Calendar | 0/TBD | Not started | - | | 5. Dashboard & Calendar | 0/TBD | Not started | - |
| 6. Desktop Client & CI/CD | 0/TBD | Not started | - | | 6. Desktop Client & CI/CD | 0/TBD | Not started | - |
@@ -0,0 +1,226 @@
---
phase: 04-marketplace-portal-navigation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/web/package.json
- apps/web/vitest.config.ts
- apps/web/src/test/setup.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/lib/stores/marketplace-store.ts
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
- apps/web/src/app/(portal)/marketplace/marketplace.test.tsx
autonomous: true
requirements: [MRKT-01, MRKT-02, MRKT-04]
must_haves:
truths:
- "User can navigate to /marketplace and see a card grid of all available modules with name, description and category badge"
- "Each card shows whether the module is activated (green status badge) or available for the current tenant"
- "Admin can click the activate button on an available module card and it becomes activated (POST /modules/:id/activate)"
- "Modules are grouped/labelled by category via a category badge on each card"
artifacts:
- path: "apps/web/src/app/(portal)/marketplace/page.tsx"
provides: "Marketplace grid page with parallel fetch of /modules + /modules/active, activation map, role gate"
min_lines: 60
- path: "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"
provides: "Module card with icon, name, localized description, category badge, status badge, activate button"
min_lines: 40
- path: "apps/web/src/lib/stores/marketplace-store.ts"
provides: "Zustand store with sidebarRefreshKey + bumpSidebarRefresh() to signal sidebar re-fetch after activation"
contains: "bumpSidebarRefresh"
- path: "apps/web/vitest.config.ts"
provides: "Vitest config for Next.js with jsdom environment"
contains: "jsdom"
- path: "apps/web/src/messages/de.json"
provides: "marketplace + sidebar.search i18n namespace (DE)"
contains: "marketplace"
key_links:
- from: "apps/web/src/app/(portal)/marketplace/page.tsx"
to: "/modules and /modules/active"
via: "Promise.all parallel fetch with credentials: include"
pattern: "modules/active"
- from: "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"
to: "/modules/:id/activate"
via: "POST fetch on activate click"
pattern: "modules/\\$\\{?.*\\}?/(activate|deactivate)"
---
<objective>
Stand up the test infrastructure (Vitest), the marketplace i18n namespace, and the first end-to-end marketplace slice: a user opens `/marketplace`, sees a responsive card grid of all modules with category and activation status, and an admin can activate an available module.
Purpose: Delivers the core marketplace browsing + activation capability (MRKT-01, MRKT-02 base, MRKT-04). This is the foundational vertical slice that Plan 02 (filters/detail/tenant-context) and Plan 03 (sidebar) build on.
Output: Vitest config + setup, marketplace + sidebar.search translations, marketplace-store (sidebar refresh signal), `/marketplace` page, MarketplaceCard component, and unit tests.
</objective>
## Phase Goal
**As a** Tessera user (admin), **I want to** browse all available modules in a categorized marketplace and activate them for my tenant, **so that** activated modules become usable in the portal.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md
@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md
@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md
@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install and configure Vitest test infrastructure for apps/web</name>
<read_first>
- apps/web/package.json (current scripts + dependencies — no test runner exists yet)
- package.json (root turbo scripts — note no `test` pipeline yet)
- apps/web/src/messages/de.json (existing namespace structure for mocking next-intl in tests)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Validation Architecture section: Wave 0 gaps, quick run command)
</read_first>
<action>
Install dev dependencies in `apps/web` via `pnpm --filter @tessera/web add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react` (use the actual package name from apps/web/package.json `name` field for the --filter target). All five packages are mainstream, high-download dev tooling — see Package Legitimacy Audit in 04-RESEARCH.md which records zero new runtime packages; these are dev-only test tooling.
Create `apps/web/vitest.config.ts` configuring the `@vitejs/plugin-react` plugin, `test.environment` set to `jsdom`, `test.globals` true, `test.setupFiles` pointing to `./src/test/setup.ts`, and a resolve alias mapping `@` to `./src` (matching the tsconfig path alias used across the app).
Create `apps/web/src/test/setup.ts` importing `@testing-library/jest-dom/vitest` so DOM matchers are registered.
Add a `"test": "vitest run"` script to `apps/web/package.json` scripts. Add a `"test": "turbo test"` pipeline entry to root `package.json` scripts and a `test` task to turbo.json if a turbo config exists.
Do NOT add any runtime dependencies — this phase ships zero new runtime packages.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run --reporter=verbose 2>&1 | grep -qiE "no test files|passed|0 passed" && echo VITEST_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/package.json devDependencies contains `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react`
- apps/web/vitest.config.ts exists and contains the string `jsdom`
- apps/web/src/test/setup.ts contains `@testing-library/jest-dom`
- apps/web/package.json scripts contains `"test"`
- `cd apps/web && pnpm vitest run` exits 0 (no failing tests; "no test files" is acceptable at this point)
</acceptance_criteria>
<done>Vitest runs in apps/web with jsdom + jest-dom matchers; `pnpm vitest run` exits 0.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add marketplace i18n namespace and build MarketplaceCard component</name>
<read_first>
- apps/web/src/messages/de.json (existing namespaces: common, sidebar, modules, adminModules — add new `marketplace` namespace and `sidebar.search` key)
- apps/web/src/messages/en.json (mirror structure for EN)
- apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx (analog: card structure, ModuleIcon inline SVG, locale extraction `description[locale] || description.en || description.de`)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (MarketplaceCard Anatomy section: padding p-5, status badge classes, activate/deactivate button classes; Copywriting Contract for all DE/EN strings)
- .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (MarketplaceCard pattern, CSS Token Convention, status color exception)
</read_first>
<behavior>
- Test 1: MarketplaceCard renders the module name and the localized description (de locale) as text content
- Test 2: MarketplaceCard renders a category badge showing the module category
- Test 3: When `isActive` is false, the card shows the activate button with the "Modul aktivieren" label and an "available" status badge
- Test 4: When `isActive` is true, the card shows a green "Aktiviert" status badge and the deactivate label "Deaktivieren"
- Test 5: Clicking the activate button calls the `onToggle` prop with the module id and the current active state
</behavior>
<action>
Add a `marketplace` namespace to both `apps/web/src/messages/de.json` and `en.json` containing every key from the 04-UI-SPEC Copywriting Contract: `title`, `subtitle`, `activate`, `deactivate`, `searchPlaceholder`, status tab keys (`statusAll`, `statusActive`, `statusAvailable`), `categoryAll`, empty-state keys (`emptyTitle`, `emptyBody`, `filteredEmptyTitle`, `filteredEmptyBody`), `errorLoad`, toast keys (`toastActivated`, `toastDeactivated`, `toastActivateError`, `toastDeactivateError`), deactivation dialog keys (`deactivateDialogTitle`, `deactivateDialogBody` with `{moduleName}` placeholder, `deactivateDialogConfirm`, `deactivateDialogCancel`), `tenantContextLabel`, `backToMarketplace`, detail keys (`detailVersion` with `{version}`, `detailStatusActive`, `detailStatusInactive`). Use the exact DE/EN strings from the Copywriting Contract. Also add a `search` key under the existing `sidebar` namespace (DE "Module suchen...", EN "Search modules...") and `sidebar.noResults` (DE "Keine Module gefunden", EN "No modules found").
Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` as a `'use client'` component. Props: `{ id: string; name: string; slug: string; description: Record<string,string>; icon?: string; category: string; isActive: boolean; isToggling: boolean; onToggle: (id: string, currentlyActive: boolean) => void }`. Reuse the ModuleIcon inline-SVG pattern and locale extraction from ModuleCard.tsx (`const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'`). Render: icon container `rounded-md bg-muted p-2.5`, module name `font-semibold text-base`, a category badge `rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground`, a status badge (active: `bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400`; available: `bg-muted text-muted-foreground`) with `role="status"`, the localized description with `line-clamp-2`, the version text, and an action button. Active card shows the deactivate button (`border border-border text-foreground hover:bg-muted`), inactive shows the activate button (`bg-primary text-primary-foreground`). When `isToggling`, disable the button and show a spinner. Button click calls `onToggle(id, isActive)`. Use `useTranslations('marketplace')`. Card container classes per UI-SPEC: `rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30`, min-height 180px.
Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx` implementing the five tests in <behavior>. Mock `next-intl`'s `useTranslations` to return the key (or a minimal lookup) so labels are assertable.
Do NOT inline-render `description` directly (it is a JSON object — would render `[object Object]`); always extract the locale value.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx" 2>&1 | grep -qiE "passed" && echo CARD_TESTS_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/src/messages/de.json contains a `marketplace` object with key `activate` equal to "Modul aktivieren"
- apps/web/src/messages/en.json contains a `marketplace` object with key `activate` equal to "Activate Module"
- apps/web/src/messages/de.json `sidebar` object contains key `search` equal to "Module suchen..."
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx contains `role="status"` and `line-clamp-2`
- MarketplaceCard.tsx extracts localized description (contains `description.en` or `description[locale]` fallback chain)
- `pnpm vitest run` on MarketplaceCard.test.tsx reports all 5 tests passed
</acceptance_criteria>
<done>MarketplaceCard renders name, localized description, category + status badges, and activate/deactivate button; clicking calls onToggle; all 5 unit tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Build the /marketplace page and marketplace-store, wiring browse + activate end-to-end</name>
<read_first>
- apps/web/src/app/(portal)/admin/modules/page.tsx (analog: parallel Promise.all fetch of /modules + /modules/active, activation Map, toggleModule, role gate `isAdmin`, error banner)
- apps/web/src/lib/stores/sidebar-store.ts (Zustand persist pattern) and apps/web/src/lib/stores/auth-store.ts (non-persisted store pattern, AuthUser.role)
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx (created Task 2 — the card this page renders)
- apps/web/src/app/(portal)/modules/[category]/page.tsx (loading spinner + empty-state pattern, responsive grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Marketplace Page layout, grid breakpoints 1/2/3/4 cols, States Matrix)
- .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (marketplace/page.tsx pattern, API Fetch Convention)
</read_first>
<behavior>
- Test 1: When fetch returns a list of modules, the page renders one MarketplaceCard per module (assert by module name text)
- Test 2: When /modules/active returns one of the modules, that module's card shows the activated status (green badge / deactivate label)
- Test 3: When a non-admin user is in the auth store, the page renders the access-denied message and no cards
- Test 4: When the modules list is empty, the page renders the empty-state heading from the marketplace namespace
</behavior>
<action>
Create `apps/web/src/lib/stores/marketplace-store.ts` as a non-persisted Zustand store: `{ sidebarRefreshKey: number; bumpSidebarRefresh: () => void; selectedTenantId: string | null; setSelectedTenantId: (id: string | null) => void }`. `bumpSidebarRefresh` increments `sidebarRefreshKey`. This store is the signal Plan 03's sidebar subscribes to for re-fetch after activation (avoids the race-condition pitfall in 04-RESEARCH.md Pitfall 2), and holds the Super-Admin tenant context used by Plan 02. Export `useMarketplaceStore`.
Create `apps/web/src/app/(portal)/marketplace/page.tsx` as a `'use client'` component following the admin/modules/page.tsx pattern: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'`. State: `modules`, `activations` (Map<string,boolean>), `loading`, `toggling`, `error`. `fetchModules` does `Promise.all([fetch('/modules'), fetch('/modules/active')])` with `credentials: 'include'`; read `selectedTenantId` from useMarketplaceStore and pass `{ 'x-tenant-id': selectedTenantId }` header on the `/modules/active` call when set (header omitted when null). Build the activation Map from the active response. `toggleModule(id, currentlyActive)` POSTs to `/modules/${id}/${currentlyActive ? 'deactivate' : 'activate'}` with `credentials: 'include'` and the same optional x-tenant-id header; on success update the activation Map optimistically and call `bumpSidebarRefresh()` so the sidebar re-fetches. Role-gate with `isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'` (from auth-store) — non-admin renders the common `accessDenied` message. Render page header (h1 `text-2xl font-bold` title, subtitle), error banner (`border-destructive/50 bg-destructive/10`), loading spinner, empty-state (illustration + `emptyTitle`/`emptyBody` from marketplace namespace), and the responsive card grid `grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4` mapping modules to `<MarketplaceCard>` passing `isActive={activations.has(mod.id)}` and `onToggle={toggleModule}`. Filters and tenant selector are added in Plan 02 — keep this page rendering all modules for now.
Create `apps/web/src/app/(portal)/marketplace/marketplace.test.tsx` implementing the four tests in <behavior>. Mock global `fetch`, the auth-store (`useAuthStore`), and `next-intl`.
Do NOT fetch activation status per-card; use one parallel fetch and a client-side Map (04-RESEARCH.md anti-pattern).
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/marketplace.test.tsx" 2>&1 | grep -qiE "passed" && echo PAGE_TESTS_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/src/lib/stores/marketplace-store.ts contains `bumpSidebarRefresh` and `selectedTenantId`
- apps/web/src/app/(portal)/marketplace/page.tsx contains `Promise.all` and `modules/active`
- page.tsx calls `bumpSidebarRefresh` after a successful toggle
- page.tsx renders the access-denied message for non-admin (`role` check against ADMIN/SUPER_ADMIN)
- `pnpm vitest run` on marketplace.test.tsx reports all 4 tests passed
- `cd apps/web && pnpm type-check` exits 0
</acceptance_criteria>
<done>Visiting /marketplace as an admin shows a card grid of all modules with correct activation status; activating a module POSTs to the activate endpoint, updates the card, and bumps the sidebar refresh signal; non-admins see access denied; all 4 page tests pass.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
Symbols created by this plan (excluded from drift verification by downstream review):
- `MarketplaceCard` (React component) — apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- `MarketplacePage` default export — apps/web/src/app/(portal)/marketplace/page.tsx
- `useMarketplaceStore` (Zustand) with fields `sidebarRefreshKey`, `bumpSidebarRefresh`, `selectedTenantId`, `setSelectedTenantId` — apps/web/src/lib/stores/marketplace-store.ts
- i18n namespace `marketplace` and keys `sidebar.search`, `sidebar.noResults` in de.json / en.json
- New file paths: apps/web/vitest.config.ts, apps/web/src/test/setup.ts, marketplace.test.tsx, MarketplaceCard.test.tsx
- New npm dev scripts: `test` in apps/web/package.json and root package.json
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → NestJS API | Authenticated user actions (browse, activate) cross from untrusted client to backend |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-01 | Elevation of Privilege | POST /modules/:id/activate | mitigate | Backend RolesGuard already requires ADMIN/SUPER_ADMIN (Phase 3, VERIFIED in module-registry.controller.ts). Frontend additionally role-gates the page with `isAdmin` check; non-admins get accessDenied. Defense-in-depth — backend is authoritative. |
| T-04-02 | Tampering (XSS) | MarketplaceCard description | mitigate | Module description rendered as React text content (not dangerouslySetInnerHTML); React auto-escapes. Locale value extracted as string. |
| T-04-03 | Spoofing | x-tenant-id header on activate | mitigate | TenantMiddleware only honors x-tenant-id when `user.role === 'SUPER_ADMIN'` (VERIFIED tenant.middleware.ts L33). Regular admin's header is ignored; their own JWT tenantId is used. |
| T-04-SC | Tampering | npm dev-dependency installs (vitest et al.) | mitigate | All five installs are mainstream test tooling with millions of weekly downloads; 04-RESEARCH Package Legitimacy Audit records zero new runtime packages. Dev-only, not shipped to production bundle. No [ASSUMED]/[SUS] packages introduced. |
</threat_model>
<verification>
- `cd apps/web && pnpm vitest run` exits 0 with all marketplace tests passing
- `cd apps/web && pnpm type-check` exits 0
- Manual: visit `/marketplace` logged in as admin — card grid renders, activation toggles status and persists across page reload
</verification>
<success_criteria>
- Vitest test infra runs in apps/web
- `/marketplace` route shows all modules as cards with category + status badges (MRKT-01, MRKT-04)
- Admin can activate a module from a card (MRKT-02 base)
- marketplace-store signals sidebar refresh and holds tenant context for downstream plans
- All unit tests green, type-check clean
</success_criteria>
<output>
Create `.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,245 @@
---
phase: 04-marketplace-portal-navigation
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx
- apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx
- apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx
- apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx
- apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx
- apps/web/src/app/(portal)/marketplace/components/Toast.tsx
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx
- apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
autonomous: true
requirements: [MRKT-01, MRKT-02, MRKT-04]
must_haves:
truths:
- "User can type in the marketplace search box and the card grid live-filters by module name and description (debounced)"
- "User can filter the grid by category chip and by status tab (Alle / Aktiviert / Verfuegbar)"
- "Super-Admin sees a tenant-context dropdown; switching tenant re-fetches activation status for that tenant and activation targets that tenant (D-04)"
- "Deactivating a module shows a confirmation dialog before the deactivate call; activation shows a success toast (no dialog)"
- "User can open a module detail page at /marketplace/[slug] showing the full description, status, and an activation button"
artifacts:
- path: "apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx"
provides: "Debounced (300ms) search input with clear button"
min_lines: 20
- path: "apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx"
provides: "Tab bar: Alle / Aktiviert / Verfuegbar with counts"
min_lines: 20
- path: "apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx"
provides: "Horizontal category chip row including 'Alle'"
min_lines: 20
- path: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx"
provides: "Super-Admin-only tenant dropdown writing selectedTenantId to marketplace-store (D-04)"
contains: "SUPER_ADMIN"
- path: "apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx"
provides: "Deactivation confirmation modal with focus trap"
min_lines: 20
- path: "apps/web/src/app/(portal)/marketplace/components/Toast.tsx"
provides: "Hand-rolled toast system (Zustand store + toast container)"
contains: "useToastStore"
- path: "apps/web/src/app/(portal)/marketplace/[slug]/page.tsx"
provides: "Compact module detail page"
min_lines: 40
key_links:
- from: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx"
to: "/tenants"
via: "fetch tenant list (SUPER_ADMIN)"
pattern: "/tenants"
- from: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx"
to: "marketplace-store selectedTenantId"
via: "setSelectedTenantId on change"
pattern: "setSelectedTenantId"
- from: "apps/web/src/app/(portal)/marketplace/page.tsx"
to: "useMemo filtered modules"
via: "search AND status AND category composition"
pattern: "filter"
---
<objective>
Refine the marketplace slice from Plan 01 with the full filtering experience (live search, status tabs, category chips), the Super-Admin tenant-context selector (D-04), asymmetric activation UX (immediate activate + toast, confirm dialog for deactivate), and the compact module detail page at `/marketplace/[slug]`.
Purpose: Completes MRKT-01 (full discovery with filters), MRKT-02 (Super-Admin per-tenant activation via context switch, D-04/D-05), and MRKT-04 (category filtering). Honors all Claude's-Discretion decisions locked in 04-UI-SPEC.
Output: Search/CategoryFilter/StatusFilter/TenantContextSelector/ActivationDialog/Toast components, the detail page, and the filtered marketplace page.
</objective>
## Phase Goal
**As a** Tessera admin (or Super-Admin), **I want to** search, filter, and inspect modules — and as a Super-Admin choose which tenant I am managing — **so that** I can confidently activate the right modules for the right tenant.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md
@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md
@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md
@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md
@.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build filter components and Toast, wire client-side filtering into the marketplace page</name>
<read_first>
- apps/web/src/app/(portal)/marketplace/page.tsx (created Plan 01 — extend with filter state + useMemo)
- apps/web/src/lib/stores/marketplace-store.ts (created Plan 01 — selectedTenantId, bumpSidebarRefresh)
- apps/web/src/app/(portal)/admin/tenants/page.tsx (form input styling `flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm`; badge styling for chips)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Interaction Contracts: Status Tabs, Category Chips, Search; Toast Notifications; Copywriting Contract)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pattern 1 client-side multi-dimension filter; Pattern 4 toast store)
- apps/web/src/messages/de.json (marketplace namespace added in Plan 01)
</read_first>
<behavior>
- Test 1: Given modules A and B, typing "A" in MarketplaceSearch filters the grid to only module A (after debounce)
- Test 2: Selecting the "Aktiviert" status tab shows only modules present in the activation map
- Test 3: Selecting a specific category chip shows only modules of that category; "Alle" shows all
- Test 4: Search + status + category filters compose (AND) together
- Test 5: When filters produce zero results, the page renders the filtered-empty heading (`filteredEmptyTitle`), distinct from the no-modules empty state
</behavior>
<action>
Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx`: controlled `<input>` with 300ms debounce (simple setTimeout/clearTimeout, no lodash per 04-RESEARCH), placeholder from `marketplace.searchPlaceholder`, a clear (X) button visible when text present, calling an `onChange(value: string)` prop with the debounced value. Use input styling from tenants page.
Create `apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx`: three tabs "Alle"/"Aktiviert"/"Verfuegbar" (labels from marketplace namespace), props `{ value: 'all'|'active'|'available'; counts: {all:number;active:number;available:number}; onChange }`. Active tab `text-foreground border-b-2 border-primary`, inactive `text-muted-foreground hover:text-foreground`, inline count badge.
Create `apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx`: horizontal pill row, first chip "Alle" then one per unique category, props `{ categories: string[]; value: string; onChange }`. Selected `bg-primary text-primary-foreground`, unselected `bg-muted text-foreground hover:bg-muted/80`, `rounded-full px-3 py-1 text-xs font-medium`, container `overflow-x-auto`.
Create `apps/web/src/app/(portal)/marketplace/components/Toast.tsx`: a Zustand `useToastStore` (per 04-RESEARCH Pattern 4 — toasts array, `addToast(type, message)` auto-removing after 4000ms via setTimeout, `removeToast`) AND a `ToastContainer` component rendering bottom-right stacked toasts (`bg-card border border-border shadow-lg rounded-lg p-4`, success green check / error red X, `motion-safe:` slide animation). Export both `useToastStore` and `ToastContainer`.
Modify `apps/web/src/app/(portal)/marketplace/page.tsx`: add state `searchQuery`, `statusFilter` ('all'), `categoryFilter` ('all'). Compute `filteredModules` via `useMemo` composing all three dimensions (search matches name + localized description; status against activation Map; category against `mod.category`) exactly per 04-RESEARCH Pattern 1. Derive unique categories and per-status counts. Render `<MarketplaceSearch>`, `<StatusFilter>`, `<CategoryFilter>` above the grid; map `filteredModules` instead of all modules. Add a filtered-empty branch (`filteredEmptyTitle`/`filteredEmptyBody`) distinct from the no-modules empty state. Mount `<ToastContainer>` and call `addToast('success', t('toastActivated'))` / `addToast('error', t('toastActivateError'))` in toggleModule for the activate path.
Create `apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx` implementing the five tests in <behavior> (use fake timers for debounce).
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/marketplace-filters.test.tsx" 2>&1 | grep -qiE "passed" && echo FILTER_TESTS_OK</automated>
</verify>
<acceptance_criteria>
- MarketplaceSearch.tsx contains a debounce (setTimeout/clearTimeout) and a clear button
- StatusFilter.tsx renders three tabs and uses `border-b-2 border-primary` for the active tab
- CategoryFilter.tsx includes an "Alle" chip and renders `rounded-full`
- Toast.tsx exports `useToastStore` and `ToastContainer`; auto-dismiss uses 4000
- page.tsx contains `useMemo` composing search, status, and category filters
- page.tsx renders a distinct filtered-empty branch using `filteredEmptyTitle`
- All 5 filter tests pass; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Search, status tabs, and category chips live-filter the grid (composing AND); zero-result filtering shows the filtered-empty state; activation fires a success toast; all 5 tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add Super-Admin TenantContextSelector and deactivation confirmation dialog (D-04, D-05)</name>
<read_first>
- apps/web/src/app/(portal)/marketplace/page.tsx (extended in Task 1 — add selector + dialog wiring)
- apps/web/src/lib/stores/marketplace-store.ts (selectedTenantId / setSelectedTenantId — created Plan 01)
- apps/web/src/lib/stores/auth-store.ts (role: SUPER_ADMIN / ADMIN / USER)
- apps/web/src/app/(portal)/admin/tenants/page.tsx (Tenant interface {id,name,slug,isActive,createdAt,userCount}; GET /tenants fetch; delete-confirm modal pattern with overlay bg-black/50, max-w-sm card, cancel/confirm buttons)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Tenant Context Selector section; Deactivation flow + dialog copy; Accessibility focus management)
- .planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md (D-04 Super-Admin context switch, D-05 regular admin direct, deferred FEAT-V2-04 full impersonation)
</read_first>
<behavior>
- Test 1: For a SUPER_ADMIN user, TenantContextSelector fetches /tenants and renders the tenant options; for a non-super-admin it renders nothing
- Test 2: Changing the selector value calls setSelectedTenantId with the chosen tenant id
- Test 3: Clicking deactivate on an active module opens ActivationDialog with the module name in the body (does NOT immediately call the API)
- Test 4: Confirming the dialog calls the deactivate handler; cancelling closes the dialog with no API call
</behavior>
<action>
Per D-04, build ONLY a lightweight tenant-context selector — NOT a full impersonation system (FEAT-V2-04 is deferred, out of scope). Create `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`: `'use client'`, render nothing unless `currentUser?.role === 'SUPER_ADMIN'` (from auth-store). Fetch `GET /tenants` with `credentials: 'include'` (Tenant interface per tenants page). Container `bg-muted/50 rounded-lg p-3 mb-4 border border-border`, label `tenantContextLabel` (12px semibold muted), native `<select>` styled `bg-card border border-border rounded-md px-3 py-1.5 text-sm`, min-width 200px, options = tenants. On change call `setSelectedTenantId(value)` from useMarketplaceStore. Show "Laden..." while fetching. Per D-05 a regular ADMIN never sees this — they operate on their own JWT tenant with no header.
Create `apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx`: controlled modal, props `{ open: boolean; moduleName: string; onConfirm: () => void; onCancel: () => void }`. Overlay `fixed inset-0 z-50 flex items-center justify-center bg-black/50`, card `max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg`. Title `deactivateDialogTitle`, body `deactivateDialogBody` with `{moduleName}` interpolation, cancel button (`deactivateDialogCancel`, outline) and confirm button (`deactivateDialogConfirm`, `bg-destructive text-destructive-foreground`). Implement a basic focus trap: on open, focus the cancel button; trap Tab within the two buttons; Escape calls onCancel; restore focus to the trigger on close.
Modify `apps/web/src/app/(portal)/marketplace/page.tsx`: render `<TenantContextSelector />` above the page title. Subscribe to `selectedTenantId` from useMarketplaceStore and re-run `fetchModules` whenever it changes (so switching tenant re-fetches activation status — per D-04). Split toggle handling: activation path stays immediate (toast); deactivation path opens `<ActivationDialog>` (track `pendingDeactivation` module), and only on confirm fire the deactivate POST (with x-tenant-id header when selectedTenantId set), then `addToast('success', t('toastDeactivated'))` and `bumpSidebarRefresh()`.
Create/extend the relevant test file (e.g. `marketplace-filters.test.tsx` or a new `tenant-selector.test.tsx`) implementing the four tests in <behavior>; mock auth-store role and fetch.
Do NOT implement session/JWT override or "act as tenant" navigation — context is display + header only (anti-pattern in 04-RESEARCH: "Building a full impersonation system").
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/marketplace" 2>&1 | grep -qiE "passed" && echo SELECTOR_TESTS_OK</automated>
</verify>
<acceptance_criteria>
- TenantContextSelector.tsx contains `SUPER_ADMIN` and returns null for non-super-admins
- TenantContextSelector.tsx fetches `/tenants` and calls `setSelectedTenantId` on change
- ActivationDialog.tsx uses `bg-destructive` confirm button and interpolates `moduleName`
- page.tsx re-fetches modules when `selectedTenantId` changes
- page.tsx opens ActivationDialog on deactivate (no immediate API call) and POSTs deactivate only on confirm
- No JWT/session override or impersonation navigation is introduced (D-04 boundary respected)
- All selector/dialog tests pass; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Super-Admin can switch tenant context and see/modify that tenant's activations; regular admin operates on own tenant; deactivation requires dialog confirmation; activation is immediate with toast; tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Build the compact module detail page at /marketplace/[slug]</name>
<read_first>
- apps/web/src/app/(portal)/modules/[category]/page.tsx (analog: useParams, loading spinner, empty state pattern)
- apps/web/src/app/(portal)/marketplace/page.tsx (fetch + activation Map conventions; toggle + toast wiring to reuse)
- apps/web/src/app/(portal)/marketplace/components/Toast.tsx (useToastStore from Task 1)
- apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx (from Task 2 — reuse for deactivate confirm)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Module Detail Page layout, max-w-2xl, icon p-4, back link, detail status copy)
- apps/web/src/messages/de.json (marketplace namespace: backToMarketplace, detailVersion, detailStatusActive, detailStatusInactive)
</read_first>
<behavior>
- Test 1: Given a slug, the detail page fetches modules and renders the matching module's name and full (non-truncated) localized description
- Test 2: When the module is active for the tenant, the page shows the active status text and a deactivate button
- Test 3: When the module slug is not found after load, the page shows a not-found / empty state
</behavior>
<action>
Create `apps/web/src/app/(portal)/marketplace/[slug]/page.tsx` as `'use client'` using `useParams()` to read `slug`. Fetch `GET /modules` + `GET /modules/active` (parallel, credentials include, x-tenant-id header from selectedTenantId when set), find the module by slug, compute its active state from the activation Map. Layout per UI-SPEC: back link (`backToMarketplace`, left chevron, `text-sm text-muted-foreground hover:text-foreground`) linking via Next.js `<Link href="/marketplace">`, larger icon container `rounded-lg bg-muted p-4`, h1 `text-2xl font-bold` name, category badge + `detailVersion` text, FULL localized description (no line-clamp), status line (`detailStatusActive`/`detailStatusInactive`), and an activate/deactivate button reusing the same handlers (immediate activate + toast; deactivate opens ActivationDialog). Constrain content to `max-w-2xl`. Show loading spinner and a not-found state if no module matches the slug after fetch completes.
Create the detail page test file implementing the three tests in <behavior>; mock useParams, fetch, and next-intl.
Use Next.js `<Link>` (not `<a>`) for the back link.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/[slug]" 2>&1 | grep -qiE "passed|no test files" && cd /home/vicolab/projects/tessera-ctl/apps/web && pnpm vitest run "src/app/(portal)/marketplace" 2>&1 | grep -qiE "passed" && echo DETAIL_TESTS_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx contains `useParams` and `max-w-2xl`
- detail page uses `<Link` for the back-to-marketplace link (no raw `<a href`)
- detail page renders the full localized description without `line-clamp`
- detail page shows `detailStatusActive`/`detailStatusInactive` based on activation Map
- detail page tests pass; `pnpm type-check` exits 0; full `pnpm vitest run` green
</acceptance_criteria>
<done>Opening /marketplace/{slug} shows the module's full detail with correct activation status and a working activate/deactivate control; tests pass.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
Symbols created by this plan (excluded from drift verification by downstream review):
- React components: `MarketplaceSearch`, `CategoryFilter`, `StatusFilter`, `TenantContextSelector`, `ActivationDialog`, `ToastContainer` (and default export of `[slug]/page.tsx` module detail page)
- `useToastStore` (Zustand) with `toasts`, `addToast`, `removeToast` — apps/web/src/app/(portal)/marketplace/components/Toast.tsx
- New i18n keys consumed (defined in Plan 01): the `marketplace` namespace filter/dialog/toast/detail keys
- Extended state on the existing marketplace page: `searchQuery`, `statusFilter`, `categoryFilter`, `pendingDeactivation`
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → NestJS API | Super-Admin tenant-context actions and activation/deactivation cross to backend |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-04 | Elevation of Privilege | TenantContextSelector x-tenant-id header | mitigate | Component only rendered for SUPER_ADMIN; backend TenantMiddleware ignores x-tenant-id unless `user.role === 'SUPER_ADMIN'` (VERIFIED tenant.middleware.ts L33). A spoofed header from a regular admin has no effect. |
| T-04-05 | Elevation of Privilege | GET /tenants in selector | accept | Endpoint already restricted to SUPER_ADMIN by backend RolesGuard (Phase 2). Frontend only calls it when role is SUPER_ADMIN; a non-super-admin calling directly gets 403 from backend. |
| T-04-06 | Tampering | Module detail localized description render | mitigate | Rendered as React text content; auto-escaped. No dangerouslySetInnerHTML. |
| T-04-07 | Repudiation/Accidental | Deactivation removing modules users depend on | mitigate | Confirmation dialog (D-UI) gates deactivation; activation is immediate but easily reversible. Asymmetric UX per UI-SPEC. |
</threat_model>
<verification>
- `cd apps/web && pnpm vitest run` exits 0 (all marketplace tests green)
- `cd apps/web && pnpm type-check` exits 0
- Manual: as Super-Admin, switch tenant in selector → activation states refresh; activate a module for tenant B and confirm it only affects tenant B
- Manual: deactivate shows dialog; cancel = no change, confirm = removed + toast
</verification>
<success_criteria>
- Live search + status tabs + category chips compose and filter the grid (MRKT-01, MRKT-04)
- Super-Admin tenant-context selector switches activation context (D-04); regular admin uses own tenant (D-05)
- Deactivation gated by confirmation dialog; activation immediate with toast
- Module detail page at /marketplace/[slug] renders full info and activation control
- No full impersonation system introduced (FEAT-V2-04 deferred)
</success_criteria>
<output>
Create `.planning/phases/04-marketplace-portal-navigation/04-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,180 @@
---
phase: 04-marketplace-portal-navigation
plan: 03
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar-search.tsx
- apps/web/src/components/layout/sidebar.test.tsx
- apps/web/src/components/layout/sidebar-search.test.tsx
autonomous: true
requirements: [MRKT-03, PRTAL-02, PRTAL-03, PRTAL-05]
must_haves:
truths:
- "Sidebar shows only the tenant's activated modules, grouped by category, with an individual clickable link per module (PRTAL-02, MRKT-03)"
- "Clicking a module link in the sidebar navigates client-side to /modules/{category}/{slug} and the module opens in the main content area (PRTAL-03)"
- "The currently open page/module is highlighted active in the sidebar via usePathname (PRTAL-03)"
- "A sidebar search field filters visible modules and categories by name in real time; non-matching categories hide (PRTAL-05)"
- "After a module is activated/deactivated in the marketplace, the sidebar refreshes its active-module list without a full page reload"
artifacts:
- path: "apps/web/src/components/layout/sidebar-search.tsx"
provides: "Sidebar search input filtering modules + categories"
min_lines: 20
- path: "apps/web/src/components/layout/sidebar.tsx"
provides: "Sidebar using Next.js Link + usePathname active state + per-module links + search + marketplace-store refresh subscription"
contains: "usePathname"
key_links:
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "/modules/active"
via: "fetch on mount and on marketplace-store sidebarRefreshKey change"
pattern: "modules/active"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "marketplace-store sidebarRefreshKey"
via: "useEffect dependency triggers re-fetch"
pattern: "sidebarRefreshKey"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "next/navigation usePathname"
via: "active-state comparison against link href"
pattern: "usePathname"
---
<objective>
Enhance the existing sidebar so it becomes the live navigation surface for activated modules: migrate all links from raw `<a>` to Next.js `<Link>`, add `usePathname`-based active highlighting, render an individual link per activated module under each category, add a sidebar search field, and subscribe to the marketplace-store refresh signal so newly activated/deactivated modules appear without a page reload.
Purpose: Delivers PRTAL-02 (sidebar shows categories + activated modules), PRTAL-03 (selected module opens in main area + active highlight), PRTAL-05 (sidebar search/filter), and MRKT-03 (only activated modules appear).
Output: Modified sidebar.tsx, new sidebar-search.tsx, and unit tests.
</objective>
## Phase Goal
**As a** Tessera user, **I want to** see my tenant's activated modules in the sidebar, search them, and click one to open it, **so that** I can navigate directly to the tools I use without leaving the portal.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md
@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md
@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md
@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md
@.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build SidebarSearch and migrate sidebar to Next.js Link + usePathname active state</name>
<read_first>
- apps/web/src/components/layout/sidebar.tsx (the file being modified — current raw `<a>` links, hardcoded Dashboard active state, categories accordion with per-module `<a>` links at lines 144-169, admin section)
- apps/web/src/app/(portal)/admin/tenants/page.tsx (input styling for the search field)
- .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (sidebar.tsx MODIFY section: Link/usePathname insertion points, active CSS classes; sidebar-search no-analog guidance)
- .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Sidebar Enhancement: SidebarSearch 36px height, placement between main nav and categories, collapsed-mode rule, active classes)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pitfall 1 raw `<a>` reloads, Pitfall 3 active state, Pitfall 5 accordion state reset)
- apps/web/src/messages/de.json (sidebar.search + sidebar.noResults added in Plan 01)
</read_first>
<behavior>
- Test 1: Sidebar renders the Dashboard and Marketplace links as Next.js Link elements (anchor with client-side href, no full reload semantics) — assert hrefs "/" and "/marketplace"
- Test 2: Given pathname "/marketplace", the Marketplace link has the active class (`bg-sidebar-accent`) and Dashboard does not
- Test 3: SidebarSearch renders an input with the `sidebar.search` placeholder and calls onChange with typed value
- Test 4: With a search term that matches no module, the sidebar renders the `sidebar.noResults` text
</behavior>
<action>
Create `apps/web/src/components/layout/sidebar-search.tsx`: `'use client'` controlled `<input>`, props `{ value: string; onChange: (v: string) => void }`, placeholder from `useTranslations('sidebar')('search')`, height 36px, `border border-border rounded-md`, focus `focus:ring-2 focus:ring-ring focus:border-transparent`, `px-3` within the sidebar nav padding. Include an accessible label (`aria-label` = placeholder). No debounce needed (client-side, tiny dataset) — filter immediately.
Modify `apps/web/src/components/layout/sidebar.tsx`:
1. Add imports `import Link from 'next/link'` and `import { usePathname } from 'next/navigation'`. Add `const pathname = usePathname()` and an `isActive(href)` helper: for "/" return `pathname === '/'`, otherwise `pathname.startsWith(href)`.
2. Replace EVERY `<a href=...>` (Dashboard, Marketplace, category links, per-module links, all admin links) with `<Link href=...>`. Remove the hardcoded Dashboard active class; instead apply active classes conditionally via `isActive(href)`: active = `bg-sidebar-accent text-sidebar-accent-foreground font-medium`, inactive = `text-sidebar-foreground hover:bg-muted` (for top-level) / muted variants for nested module links. Per-module links use `/modules/{category}/{slug}` and get active highlight when `isActive` matches that path.
3. Insert `<SidebarSearch>` between the main-nav `<ul>` (Dashboard/Marketplace) and the categories accordion, only when `!isCollapsed` (collapsed mode hides search per UI-SPEC). Add `searchQuery` state.
4. Filter the activated-module list and categories by `searchQuery` (case-insensitive match on module name and category name). Hide categories whose modules all filter out. When the search yields zero modules, render `t('noResults')` instead of the accordion list. Keep accordion expand/collapse state stable across search changes (track expanded categories in a Set keyed by category name so clearing search does not collapse them — Pitfall 5).
Create `apps/web/src/components/layout/sidebar-search.test.tsx` and `apps/web/src/components/layout/sidebar.test.tsx` implementing the four <behavior> tests. Mock `next/navigation` `usePathname`, `next-intl`, the auth-store, and `fetch` for `/modules/active`.
Do NOT leave any raw `<a href>` in the sidebar (causes full reloads — Pitfall 1). Do NOT show the search field or module list in collapsed (64px) mode.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/components/layout/sidebar" 2>&1 | grep -qiE "passed" && grep -vE '^\s*//|^\s*\*' src/components/layout/sidebar.tsx | grep -c '<a href' | grep -qx 0 && echo SIDEBAR_OK</automated>
</verify>
<acceptance_criteria>
- apps/web/src/components/layout/sidebar.tsx contains `usePathname` and `import Link from 'next/link'`
- sidebar.tsx contains zero `<a href` occurrences (excluding comments) — all migrated to `<Link>`
- sidebar.tsx applies `bg-sidebar-accent` via `isActive(...)` (not hardcoded on Dashboard)
- sidebar-search.tsx renders an input with the `sidebar.search` placeholder and an `aria-label`
- SidebarSearch is rendered only when `!isCollapsed`
- sidebar.test.tsx + sidebar-search.test.tsx all pass; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Sidebar uses Next.js Link with dynamic active highlighting, shows per-module links under categories, and a search field filters modules/categories (hidden when collapsed); all tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Subscribe sidebar to marketplace-store refresh signal so activations appear live</name>
<read_first>
- apps/web/src/components/layout/sidebar.tsx (modified in Task 1 — has fetchActiveModules useCallback + useEffect)
- apps/web/src/lib/stores/marketplace-store.ts (created Plan 01 — sidebarRefreshKey, bumpSidebarRefresh)
- .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pitfall 2 race condition + Open Question 1: shared Zustand store as refresh mechanism; anti-pattern: re-fetch on every navigation)
</read_first>
<behavior>
- Test 1: When `bumpSidebarRefresh()` is called on the marketplace-store, the sidebar re-invokes its fetch of /modules/active (assert fetch call count increases)
- Test 2: The sidebar does NOT re-fetch /modules/active purely because pathname changed (navigation alone must not trigger a re-fetch)
</behavior>
<action>
Modify `apps/web/src/components/layout/sidebar.tsx` to read `sidebarRefreshKey` from `useMarketplaceStore` and add it to the dependency array of the existing `useEffect` that calls `fetchActiveModules`. Keep the mount fetch. Do NOT add `pathname` to that effect's dependency array (re-fetching on every navigation is the anti-pattern in 04-RESEARCH). This makes the sidebar re-fetch active modules exactly when the marketplace bumps the signal after a successful activate/deactivate, closing the Pitfall 2 race (marketplace awaits the POST response before bumping).
Update `apps/web/src/components/layout/sidebar.test.tsx` (or add a focused test) implementing the two <behavior> tests: render the sidebar, capture fetch call count, call `useMarketplaceStore.getState().bumpSidebarRefresh()` (or trigger via the store) and assert the fetch count increased; separately change the mocked pathname and assert the fetch count did not change.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/components/layout/sidebar" 2>&1 | grep -qiE "passed" && cd /home/vicolab/projects/tessera-ctl/apps/web && pnpm vitest run 2>&1 | grep -qiE "passed" && echo REFRESH_OK</automated>
</verify>
<acceptance_criteria>
- sidebar.tsx imports `useMarketplaceStore` and reads `sidebarRefreshKey`
- `sidebarRefreshKey` is in the fetchActiveModules useEffect dependency array
- `pathname` is NOT in that effect's dependency array
- Test proves bumpSidebarRefresh triggers a re-fetch and navigation alone does not
- Full `pnpm vitest run` in apps/web exits 0; `pnpm type-check` exits 0
</acceptance_criteria>
<done>Activating/deactivating a module in the marketplace causes the sidebar to refresh its module list without a full page reload; navigation alone does not trigger refetch; tests pass.</done>
</task>
</tasks>
<artifacts_this_phase_produces>
Symbols created/modified by this plan (excluded from drift verification by downstream review):
- React component `SidebarSearch` — apps/web/src/components/layout/sidebar-search.tsx
- Modified `Sidebar` component: now imports `usePathname`, `Link`, `useMarketplaceStore`; adds `isActive` helper, `searchQuery` state, expanded-categories Set
- New test files: sidebar.test.tsx, sidebar-search.test.tsx
- Consumes (from Plan 01): `useMarketplaceStore.sidebarRefreshKey`, `sidebar.search` / `sidebar.noResults` i18n keys
</artifacts_this_phase_produces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → NestJS API | Sidebar fetches the tenant's active modules (GET /modules/active) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-08 | Information Disclosure | Sidebar /modules/active list | mitigate | GET /modules/active is tenant-scoped server-side (TenantMiddleware + RLS, Phase 2/3). Sidebar renders whatever the backend returns for the authenticated tenant — no client-side tenant selection here. |
| T-04-09 | Tampering | Module name rendered in sidebar link | mitigate | Rendered as React text content; auto-escaped. Module slug used in href is a registry-controlled slug, not free user input. |
| T-04-10 | Denial of Service | Sidebar re-fetch loop | mitigate | Re-fetch keyed only on mount + explicit sidebarRefreshKey bump; pathname deliberately excluded from deps to avoid per-navigation fetch storms (anti-pattern guarded). |
</threat_model>
<verification>
- `cd apps/web && pnpm vitest run` exits 0 (all sidebar tests green)
- `cd apps/web && pnpm type-check` exits 0
- Manual: activate a module in marketplace → it appears in sidebar without page reload; click it → opens in main area and sidebar highlights it; clear-then-type in sidebar search keeps expanded categories
</verification>
<success_criteria>
- Sidebar shows only activated modules grouped by category with per-module links (PRTAL-02, MRKT-03)
- Clicking a module opens it in the main area client-side with active highlight (PRTAL-03)
- Sidebar search filters modules/categories in real time (PRTAL-05)
- Sidebar refreshes on marketplace activation without full reload; no per-navigation re-fetch
- All raw `<a>` links migrated to Next.js `<Link>`
</success_criteria>
<output>
Create `.planning/phases/04-marketplace-portal-navigation/04-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,103 @@
---
phase: 04-marketplace-portal-navigation
plan: 04
type: execute
wave: 3
depends_on: ["04-02", "04-03"]
files_modified: []
autonomous: false
requirements: [MRKT-01, MRKT-02, MRKT-03, MRKT-04, PRTAL-02, PRTAL-03, PRTAL-05]
must_haves:
truths:
- "A human confirms the marketplace, activation flows, tenant context switching, and sidebar navigation all work end-to-end in a running stack"
artifacts: []
key_links: []
---
<objective>
Human visual + functional verification of the complete Phase 4 experience: marketplace browsing/filtering, module activation/deactivation (admin + Super-Admin tenant context), module detail page, and sidebar navigation with search and live refresh.
Purpose: Phase gate. Confirms the implemented slices satisfy the phase success criteria before the phase is marked complete.
Output: Human sign-off (or a list of issues to feed gap-closure planning).
</objective>
## Phase Goal
**As a** Tessera user, **I want to** browse and activate modules in a marketplace and navigate to them from the sidebar, **so that** the platform's module experience is usable end-to-end.
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md
@.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md
@.planning/phases/04-marketplace-portal-navigation/04-02-SUMMARY.md
@.planning/phases/04-marketplace-portal-navigation/04-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Ensure the stack is running and the full test suite is green</name>
<read_first>
- apps/web/package.json (test + dev scripts)
- .planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md
- .planning/phases/04-marketplace-portal-navigation/04-02-SUMMARY.md
- .planning/phases/04-marketplace-portal-navigation/04-03-SUMMARY.md
</read_first>
<action>
Run the full web unit suite (`cd apps/web && pnpm vitest run`) and `pnpm type-check`. Ensure the Docker Compose stack (or local dev servers) is up so the portal is reachable for human verification — start it if it is not already running. Confirm the API is serving `/modules`, `/modules/active`, and `/tenants`. Report the portal URL for the human verifier.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run 2>&1 | grep -qiE "passed" && pnpm type-check && echo SUITE_GREEN</automated>
</verify>
<acceptance_criteria>
- `cd apps/web && pnpm vitest run` exits 0
- `cd apps/web && pnpm type-check` exits 0
- Portal is reachable and the marketplace route loads without console errors
</acceptance_criteria>
<done>Full suite green, type-check clean, portal running and reachable.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Phase 4 marketplace + portal navigation: `/marketplace` card grid with search/status/category filters, immediate activation with toast + confirm-dialog deactivation, Super-Admin tenant-context selector, `/marketplace/[slug]` detail page, and an enhanced sidebar (Next.js Link, usePathname active state, per-module links, search, live refresh).
</what-built>
<how-to-verify>
1. Log in as a regular Admin. Visit `/marketplace`. CONFIRM: a responsive card grid of all modules appears, each card shows icon, name, description, a category badge, and a status badge (MRKT-01, MRKT-04).
2. Type part of a module name in the marketplace search. CONFIRM: the grid live-filters (debounced). Clear it with the X button.
3. Click the status tabs "Aktiviert" / "Verfuegbar" and a category chip. CONFIRM: the grid filters correctly and filters compose. With an impossible filter combo, CONFIRM the "Keine Ergebnisse" filtered-empty state appears.
4. On an available module, click "Modul aktivieren". CONFIRM: button becomes "Deaktivieren", status badge turns green "Aktiviert", and a success toast appears bottom-right (MRKT-02).
5. Look at the left sidebar WITHOUT reloading the page. CONFIRM: the just-activated module now appears under its category as a clickable link (MRKT-03, PRTAL-02, live refresh).
6. Click that module link in the sidebar. CONFIRM: it navigates client-side (no full-page flash), opens in the main content area, and the sidebar highlights it as active (PRTAL-03).
7. Type in the sidebar search field. CONFIRM: modules/categories filter; non-matching categories hide; "Keine Module gefunden" shows when nothing matches; clearing the search keeps previously expanded categories open (PRTAL-05).
8. Click "Deaktivieren" on an activated module in the marketplace. CONFIRM: a confirmation dialog appears with the module name; Abbrechen cancels with no change; Deaktivieren removes it (badge + sidebar update) with a toast.
9. Open a module detail page by navigating to `/marketplace/{slug}`. CONFIRM: full (untruncated) description, version, status, and a working activate/deactivate control; the "Zurueck zum Marktplatz" back link works.
10. Log in as a Super-Admin. Visit `/marketplace`. CONFIRM: a "Mandanten-Kontext" dropdown appears above the title. Switch to a different tenant. CONFIRM: card activation states refresh for that tenant; activating a module affects ONLY the selected tenant (verify by switching back).
11. Switch language DE/EN. CONFIRM: all marketplace and sidebar strings translate (no raw keys, no `[object Object]` descriptions).
12. Toggle light/dark theme. CONFIRM: cards, badges, toasts, and dialog all render correctly in both modes.
</how-to-verify>
<resume-signal>Type "approved" if all checks pass, or describe the specific issues observed (these feed `/gsd-plan-phase --gaps`).</resume-signal>
</task>
</tasks>
<artifacts_this_phase_produces>
No new symbols — verification-only plan.
</artifacts_this_phase_produces>
<verification>
- Full web suite green and type-check clean (Task 1)
- Human confirms all 12 verification steps (Task 2)
</verification>
<success_criteria>
- All phase success criteria from ROADMAP Phase 4 confirmed by a human in a running stack
- Any defects captured for gap-closure planning
</success_criteria>
<output>
Create `.planning/phases/04-marketplace-portal-navigation/04-04-SUMMARY.md` when done
</output>