Files
2026-06-22 14:20:01 +02:00

246 lines
21 KiB
Markdown

---
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) (D-02)"
- "User can filter the grid by category chip and by status tab (Alle / Aktiviert / Verfuegbar) (D-02)"
- "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>