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:
@@ -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>
|
||||
Reference in New Issue
Block a user