0470d500b2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
227 lines
20 KiB
Markdown
227 lines
20 KiB
Markdown
---
|
|
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>
|