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

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-marketplace-portal-navigation 02 execute 2
04-01
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
true
MRKT-01
MRKT-02
MRKT-04
truths artifacts key_links
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
path provides min_lines
apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx Debounced (300ms) search input with clear button 20
path provides min_lines
apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx Tab bar: Alle / Aktiviert / Verfuegbar with counts 20
path provides min_lines
apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx Horizontal category chip row including 'Alle' 20
path provides contains
apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx Super-Admin-only tenant dropdown writing selectedTenantId to marketplace-store (D-04) SUPER_ADMIN
path provides min_lines
apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx Deactivation confirmation modal with focus trap 20
path provides contains
apps/web/src/app/(portal)/marketplace/components/Toast.tsx Hand-rolled toast system (Zustand store + toast container) useToastStore
path provides min_lines
apps/web/src/app/(portal)/marketplace/[slug]/page.tsx Compact module detail page 40
from to via pattern
apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx /tenants fetch tenant list (SUPER_ADMIN) /tenants
from to via pattern
apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx marketplace-store selectedTenantId setSelectedTenantId on change setSelectedTenantId
from to via pattern
apps/web/src/app/(portal)/marketplace/page.tsx useMemo filtered modules search AND status AND category composition filter
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.

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>

@.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 Task 1: Build filter components and Toast, wire client-side filtering into the marketplace page - 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) - 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 Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx`: controlled `` 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 ``, ``, `` above the grid; map `filteredModules` instead of all modules. Add a filtered-empty branch (`filteredEmptyTitle`/`filteredEmptyBody`) distinct from the no-modules empty state. Mount `` 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 (use fake timers for debounce). cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/marketplace-filters.test.tsx" 2>&1 | grep -qiE "passed" && echo FILTER_TESTS_OK - 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 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. Task 2: Add Super-Admin TenantContextSelector and deactivation confirmation dialog (D-04, D-05) - 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) - 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 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 `` 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 `` 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 `` (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 ; 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"). cd apps/web && pnpm vitest run "src/app/(portal)/marketplace" 2>&1 | grep -qiE "passed" && echo SELECTOR_TESTS_OK - 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 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. Task 3: Build the compact module detail page at /marketplace/[slug] - 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) - 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 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 ``, 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 ; mock useParams, fetch, and next-intl. Use Next.js `` (not ``) for the back link. 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 - apps/web/src/app/(portal)/marketplace/[slug]/page.tsx contains `useParams` and `max-w-2xl` - detail page uses ` Opening /marketplace/{slug} shows the module's full detail with correct activation status and a working activate/deactivate control; tests pass. <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> - `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 <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> Create `.planning/phases/04-marketplace-portal-navigation/04-02-SUMMARY.md` when done