From 2e7daa481e39e0f2c212891b7bc9adbfdb295d73 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:49:46 +0200 Subject: [PATCH] feat(15-08): Marketplace-Karte mit drittem Zustand "Kein Zugriff" - marketplace/page.tsx und marketplace/[slug]/page.tsx auf GET /modules/catalog umgestellt (ein Aufruf statt zwei), beide Statusflags (isActiveForTenant, hasAccess) kommen in einer Antwort -> kein Zwischenzustand, in dem eine Karte kurzzeitig ohne Sperr-Badge anklickbar erscheint - MarketplaceCard bekommt hasAccess-Prop: drittes Badge (Bernstein, "Kein Zugriff") bei isActive && !hasAccess, Karte opacity-60/ cursor-not-allowed, Klick loest Toast statt Navigation aus; bei Zugriff navigiert der Klick zu /marketplace/[slug]; Badge-Reihe bekommt flex-wrap gegen Overflow bei langen Namen - [Rule 2] Marketplace-Ansicht war zuvor komplett isAdmin-gated (Zugriff verweigert fuer USER) - das widersprach D-08 ("Katalog bleibt Schaufenster fuer jeden authentifizierten Benutzer") und haette das neue Sperr-Badge fuer USER nie sichtbar gemacht. isAdmin gated jetzt nur noch die Aktivieren/Deaktivieren-Aktion (canManage), nicht mehr die gesamte Seite - bestehende Marketplace-Tests auf einaufrufiges Catalog-Mock umgestellt, "access-denied fuer non-admin"-Test durch "Karten sichtbar, aber ohne Manage-Button" ersetzt --- .../marketplace/[slug]/detail.test.tsx | 34 +++---- .../app/(portal)/marketplace/[slug]/page.tsx | 95 +++++++++--------- .../components/MarketplaceCard.test.tsx | 88 +++++++++++++++++ .../components/MarketplaceCard.tsx | 96 +++++++++++++----- .../marketplace/marketplace-filters.test.tsx | 26 +++-- .../(portal)/marketplace/marketplace.test.tsx | 65 ++++++------ .../web/src/app/(portal)/marketplace/page.tsx | 98 ++++++++++--------- .../marketplace/tenant-selector.test.tsx | 27 ++--- 8 files changed, 339 insertions(+), 190 deletions(-) diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx index c8dfced..7ea30a8 100644 --- a/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx +++ b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx @@ -54,7 +54,9 @@ vi.mock('@/lib/stores/marketplace-store', () => ({ }), })); -const mockModules = [ +// GET /modules/catalog (Plan 15-03/15-08, D-08): full module record plus +// isActiveForTenant/hasAccess in one response. +const mockCatalog = [ { id: 'mod-1', slug: 'domaincheck', @@ -64,6 +66,8 @@ const mockModules = [ description: { de: 'Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs', en: 'Check domain availability' }, icon: 'Globe', isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, { id: 'mod-2', @@ -74,10 +78,15 @@ const mockModules = [ description: { de: 'Dateien konvertieren', en: 'Convert files' }, icon: null, isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, ]; -const mockActiveModules = [mockModules[0]]; +const mockCatalogActive = [ + { ...mockCatalog[0], isActiveForTenant: true, hasAccess: true }, + mockCatalog[1], +]; afterEach(() => { cleanup(); @@ -104,11 +113,8 @@ describe('Module Detail Page', () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), @@ -128,11 +134,8 @@ describe('Module Detail Page', () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), @@ -152,11 +155,8 @@ describe('Module Detail Page', () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx index 0a471b9..4c622bb 100644 --- a/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx +++ b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx @@ -11,7 +11,12 @@ import { ActivationDialog } from '../components/ActivationDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -interface Module { +/** + * Module entry from GET /modules/catalog — see marketplace/page.tsx for + * the full rationale (D-08, Plan 15-03): both status flags in one + * response so the detail page never shows a stale access state either. + */ +interface CatalogModule { id: string; slug: string; name: string; @@ -20,6 +25,8 @@ interface Module { description: Record; icon: string | null; isSystem: boolean; + isActiveForTenant: boolean; + hasAccess: boolean; } export default function ModuleDetailPage() { @@ -29,13 +36,14 @@ export default function ModuleDetailPage() { const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); const addToast = useToastStore((s) => s.addToast); - const [module, setModule] = useState(null); - const [isActive, setIsActive] = useState(false); + const [module, setModule] = useState(null); const [loading, setLoading] = useState(true); const [toggling, setToggling] = useState(false); const [notFound, setNotFound] = useState(false); const [pendingDeactivation, setPendingDeactivation] = useState(false); + // isAdmin gates module MANAGEMENT (activate/deactivate) only — the + // catalog itself stays open to every authenticated user (D-08). const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de'; @@ -46,20 +54,13 @@ export default function ModuleDetailPage() { headers['x-tenant-id'] = selectedTenantId; } - const [allRes, activeRes] = await Promise.all([ - fetch(`${API_URL}/modules`, { credentials: 'include' }), - fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }), - ]); + const res = await fetch(`${API_URL}/modules/catalog`, { credentials: 'include', headers }); - if (allRes.ok) { - const allModules: Module[] = await allRes.json(); - const found = allModules.find((m) => m.slug === slug); + if (res.ok) { + const catalog: CatalogModule[] = await res.json(); + const found = catalog.find((m) => m.slug === slug); if (found) { setModule(found); - if (activeRes.ok) { - const activeModules: Module[] = await activeRes.json(); - setIsActive(activeModules.some((m) => m.id === found.id)); - } } else { setNotFound(true); } @@ -72,15 +73,11 @@ export default function ModuleDetailPage() { }, [slug, selectedTenantId]); useEffect(() => { - if (isAdmin) { - fetchModule(); - } else { - setLoading(false); - } - }, [isAdmin, fetchModule]); + fetchModule(); + }, [fetchModule]); const handleToggle = () => { - if (isActive) { + if (module?.isActiveForTenant) { setPendingDeactivation(true); } else { performToggle(); @@ -91,7 +88,8 @@ export default function ModuleDetailPage() { if (!module) return; setToggling(true); try { - const action = isActive ? 'deactivate' : 'activate'; + const wasActive = module.isActiveForTenant; + const action = wasActive ? 'deactivate' : 'activate'; const headers: Record = {}; if (selectedTenantId) { headers['x-tenant-id'] = selectedTenantId; @@ -104,11 +102,13 @@ export default function ModuleDetailPage() { }); if (res.ok) { - setIsActive(!isActive); + setModule((prev) => + prev ? { ...prev, isActiveForTenant: !wasActive, hasAccess: !wasActive } : prev, + ); bumpSidebarRefresh(); - addToast('success', isActive ? t('toastDeactivated') : t('toastActivated')); + addToast('success', wasActive ? t('toastDeactivated') : t('toastActivated')); } else { - addToast('error', isActive ? t('toastDeactivateError') : t('toastActivateError')); + addToast('error', wasActive ? t('toastDeactivateError') : t('toastActivateError')); } } catch { // silently fail @@ -130,14 +130,6 @@ export default function ModuleDetailPage() { ); } - if (!isAdmin) { - return ( -
-

{t('accessDenied')}

-
- ); - } - if (notFound || !module) { return (
@@ -153,6 +145,7 @@ export default function ModuleDetailPage() { } const localizedDescription = module.description[locale] || module.description.en || module.description.de || ''; + const isActive = module.isActiveForTenant; return (
@@ -192,23 +185,25 @@ export default function ModuleDetailPage() { {isActive ? t('detailStatusActive') : t('detailStatusInactive')} - + {isAdmin && ( + + )}
({ deactivate: 'Deaktivieren', statusActive: 'Aktiviert', statusAvailable: 'Verfuegbar', + statusNoAccess: 'Kein Zugriff', }; return translations[key] ?? key; }, @@ -25,8 +26,12 @@ const baseProps = { icon: 'Globe', category: 'Domain-Tools', isActive: false, + hasAccess: true, + canManage: true, isToggling: false, onToggle: vi.fn(), + onOpenDetail: vi.fn(), + onLockedClick: vi.fn(), }; afterEach(() => { @@ -75,4 +80,87 @@ describe('MarketplaceCard', () => { expect(onToggle).toHaveBeenCalledOnce(); expect(onToggle).toHaveBeenCalledWith('mod-1', false); }); + + it('shows the "Kein Zugriff" badge for an active module without access and clicking the card triggers the toast instead of navigating (D-08)', async () => { + const onOpenDetail = vi.fn(); + const onLockedClick = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.getByText('Kein Zugriff')).toBeInTheDocument(); + + await user.click(screen.getByText('Domaincheck')); + + expect(onLockedClick).toHaveBeenCalledOnce(); + expect(onOpenDetail).not.toHaveBeenCalled(); + }); + + it('does not show the "Kein Zugriff" badge for an active module with access and clicking the card navigates', async () => { + const onOpenDetail = vi.fn(); + const onLockedClick = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument(); + + await user.click(screen.getByText('Domaincheck')); + + expect(onOpenDetail).toHaveBeenCalledOnce(); + expect(onOpenDetail).toHaveBeenCalledWith('domaincheck'); + expect(onLockedClick).not.toHaveBeenCalled(); + }); + + it('leaves the not-activated state unchanged: no access badge, and clicking the card does not fire navigate or locked callbacks', async () => { + const onOpenDetail = vi.fn(); + const onLockedClick = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument(); + + await user.click(screen.getByText('Domaincheck')); + + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(onLockedClick).not.toHaveBeenCalled(); + }); + + it('renders the badge row with wrap behavior so a third badge does not overflow the card', () => { + render(); + + const statusBadge = screen.getByRole('status'); + expect(statusBadge.parentElement).toHaveClass('flex-wrap'); + }); + + it('hides the activate/deactivate button when canManage is false', () => { + render(); + + expect(screen.queryByRole('button', { name: 'Modul aktivieren' })).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx b/apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx index 5805844..9d768f3 100644 --- a/apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx +++ b/apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx @@ -13,8 +13,16 @@ export interface MarketplaceCardProps { icon?: string; category: string; isActive: boolean; + /** Whether the current user has an effective grant for this module (D-08). ADMIN/SUPER_ADMIN always true when active (D-03 bypass). */ + hasAccess: boolean; + /** Whether the current user may activate/deactivate this module (ADMIN/SUPER_ADMIN). Hides the toggle button otherwise — activation is a rollengeschuetzter Endpoint. */ + canManage: boolean; isToggling: boolean; onToggle: (id: string, currentlyActive: boolean) => void; + /** Card click on an active module the user has access to — navigates to the module detail page. */ + onOpenDetail: (slug: string) => void; + /** Card click on an active module the user lacks a grant for — shows the toastNoAccess hint instead of navigating (D-08). */ + onLockedClick: () => void; } /** @@ -77,15 +85,43 @@ export function MarketplaceCard({ icon, category, isActive, + hasAccess, + canManage, isToggling, onToggle, + onOpenDetail, + onLockedClick, }: MarketplaceCardProps) { const t = useTranslations('marketplace'); const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; const localizedDescription = description[locale] || description.en || description.de || ''; + // Third state (D-08): activated for the tenant, but this user has no + // effective grant. ADMIN/SUPER_ADMIN always have hasAccess===true for + // an active module (D-03 bypass), so this never applies to them. + const locked = isActive && !hasAccess; + + const handleCardClick = () => { + if (locked) { + onLockedClick(); + } else if (isActive) { + onOpenDetail(slug); + } + // Not-yet-activated cards have no card-level click behavior — unchanged. + }; + return ( -
+
{/* Header: icon + name + badges */}
@@ -93,7 +129,7 @@ export function MarketplaceCard({

{name}

-
+
{category} @@ -107,6 +143,17 @@ export function MarketplaceCard({ > {isActive ? t('statusActive') : t('statusAvailable')} + {locked && ( + + {t('statusNoAccess')} + + )}
@@ -116,26 +163,31 @@ export function MarketplaceCard({ {localizedDescription}

- {/* Footer: action button */} -
- -
+ {/* Footer: action button — only rendered for users who may activate/deactivate */} + {canManage && ( +
+ +
+ )}
); } diff --git a/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx index f46be29..19c2f19 100644 --- a/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx +++ b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx @@ -47,7 +47,14 @@ vi.mock('@/lib/stores/marketplace-store', () => ({ }), })); -const mockModules = [ +// marketplace/page.tsx navigates to the detail page on card click (D-08) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +// GET /modules/catalog (Plan 15-03/15-08, D-08): full module record plus +// isActiveForTenant/hasAccess in one response. +const mockCatalog = [ { id: 'mod-1', slug: 'domaincheck', @@ -57,6 +64,8 @@ const mockModules = [ description: { de: 'Domain pruefen', en: 'Check domain' }, icon: 'Globe', isSystem: false, + isActiveForTenant: true, // Domaincheck is active + hasAccess: true, }, { id: 'mod-2', @@ -67,6 +76,8 @@ const mockModules = [ description: { de: 'Dateien konvertieren', en: 'Convert files' }, icon: null, isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, { id: 'mod-3', @@ -77,22 +88,17 @@ const mockModules = [ description: { de: 'E-Mails verwalten', en: 'Manage emails' }, icon: null, isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, ]; -const mockActiveModules = [ - mockModules[0], // Domaincheck is active -]; - function stubFetch() { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), diff --git a/apps/web/src/app/(portal)/marketplace/marketplace.test.tsx b/apps/web/src/app/(portal)/marketplace/marketplace.test.tsx index 7e71c37..ff06629 100644 --- a/apps/web/src/app/(portal)/marketplace/marketplace.test.tsx +++ b/apps/web/src/app/(portal)/marketplace/marketplace.test.tsx @@ -35,7 +35,14 @@ vi.mock('@/lib/stores/marketplace-store', () => ({ }), })); -const mockModules = [ +// marketplace/page.tsx navigates to the detail page on card click (D-08) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +// GET /modules/catalog (Plan 15-03/15-08, D-08): full module record plus +// isActiveForTenant/hasAccess in one response. +const mockCatalog = [ { id: 'mod-1', slug: 'domaincheck', @@ -45,6 +52,8 @@ const mockModules = [ description: { de: 'Domain pruefen', en: 'Check domain' }, icon: 'Globe', isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, { id: 'mod-2', @@ -55,20 +64,14 @@ const mockModules = [ description: { de: 'Dateien konvertieren', en: 'Convert files' }, icon: null, isSystem: false, + isActiveForTenant: false, + hasAccess: false, }, ]; -const mockActiveModules = [ - { - id: 'mod-1', - slug: 'domaincheck', - name: 'Domaincheck', - version: '1.0.0', - category: 'Domain-Tools', - description: { de: 'Domain pruefen', en: 'Check domain' }, - icon: 'Globe', - isSystem: false, - }, +const mockCatalogOneActive = [ + { ...mockCatalog[0], isActiveForTenant: true, hasAccess: true }, + mockCatalog[1], ]; afterEach(() => { @@ -96,11 +99,8 @@ describe('MarketplacePage', () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), @@ -115,15 +115,12 @@ describe('MarketplacePage', () => { expect(screen.getByText('Converter')).toBeInTheDocument(); }); - it('shows activated status for a module returned by /modules/active', async () => { + it('shows activated status for a module with isActiveForTenant true', async () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogOneActive) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); }), @@ -143,28 +140,36 @@ describe('MarketplacePage', () => { expect(texts).toContain('Verfuegbar'); }); - it('renders access-denied and no cards for non-admin users', async () => { + it('renders cards for non-admin users too, but without the manage button (D-08: catalog is a showcase for every authenticated user)', async () => { mockAuthStore.mockImplementation((selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) => selector({ user: { id: 'u2', username: 'user', displayName: 'User', role: 'USER', tenantId: 't1' }, }), ); + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) }); + } + return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); + }), + ); const Page = await importPage(); render(); - expect(screen.getByText('Zugriff verweigert')).toBeInTheDocument(); - expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'Modul aktivieren' })).not.toBeInTheDocument(); }); it('renders empty-state heading when no modules are available', async () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); - } - if (url.includes('/modules')) { + if (url.includes('/modules/catalog')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }); diff --git a/apps/web/src/app/(portal)/marketplace/page.tsx b/apps/web/src/app/(portal)/marketplace/page.tsx index 38c0df9..251b9bd 100644 --- a/apps/web/src/app/(portal)/marketplace/page.tsx +++ b/apps/web/src/app/(portal)/marketplace/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/stores/auth-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { MarketplaceCard } from './components/MarketplaceCard'; @@ -14,7 +15,15 @@ import { ActivationDialog } from './components/ActivationDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -interface Module { +/** + * Module entry from GET /modules/catalog — the full module record plus + * both status flags in one response (D-08, Plan 15-03): isActiveForTenant + * (mandantenweite Aktivierung) and hasAccess (ModuleAccessService's + * per-user resolution, D-01). Both flags arriving together is what + * prevents a card from ever rendering active-and-clickable before its + * lock badge catches up. + */ +interface CatalogModule { id: string; slug: string; name: string; @@ -23,16 +32,18 @@ interface Module { description: Record; icon: string | null; isSystem: boolean; + isActiveForTenant: boolean; + hasAccess: boolean; } export default function MarketplacePage() { const t = useTranslations('marketplace'); + const router = useRouter(); const currentUser = useAuthStore((s) => s.user); const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); const addToast = useToastStore((s) => s.addToast); - const [modules, setModules] = useState([]); - const [activations, setActivations] = useState>(new Map()); + const [modules, setModules] = useState([]); const [loading, setLoading] = useState(true); const [toggling, setToggling] = useState(null); const [error, setError] = useState(null); @@ -42,6 +53,9 @@ export default function MarketplacePage() { const [categoryFilter, setCategoryFilter] = useState('all'); const [pendingDeactivation, setPendingDeactivation] = useState<{ id: string; name: string } | null>(null); + // isAdmin gates module MANAGEMENT (activate/deactivate) only — the + // catalog itself stays open to every authenticated user (D-08): the + // marketplace is a showcase, not an access decision. const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const fetchModules = useCallback(async () => { @@ -51,23 +65,13 @@ export default function MarketplacePage() { headers['x-tenant-id'] = selectedTenantId; } - const [allRes, activeRes] = await Promise.all([ - fetch(`${API_URL}/modules`, { credentials: 'include' }), - fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }), - ]); + const res = await fetch(`${API_URL}/modules/catalog`, { credentials: 'include', headers }); - if (allRes.ok) { - const allModules: Module[] = await allRes.json(); - setModules(allModules); - } - - if (activeRes.ok) { - const activeModules: Module[] = await activeRes.json(); - const map = new Map(); - for (const mod of activeModules) { - map.set(mod.id, true); - } - setActivations(map); + if (res.ok) { + const catalog: CatalogModule[] = await res.json(); + setModules(catalog); + } else { + setError(t('errorLoad')); } } catch { setError(t('errorLoad')); @@ -77,12 +81,8 @@ export default function MarketplacePage() { }, [selectedTenantId, t]); useEffect(() => { - if (isAdmin) { - fetchModules(); - } else { - setLoading(false); - } - }, [isAdmin, fetchModules]); + fetchModules(); + }, [fetchModules]); const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de'; @@ -96,12 +96,12 @@ export default function MarketplacePage() { return false; } } - if (statusFilter === 'active' && !activations.has(mod.id)) return false; - if (statusFilter === 'available' && activations.has(mod.id)) return false; + if (statusFilter === 'active' && !mod.isActiveForTenant) return false; + if (statusFilter === 'available' && mod.isActiveForTenant) return false; if (categoryFilter !== 'all' && mod.category !== categoryFilter) return false; return true; }); - }, [modules, activations, searchQuery, statusFilter, categoryFilter, locale]); + }, [modules, searchQuery, statusFilter, categoryFilter, locale]); const categories = useMemo( () => [...new Set(modules.map((m) => m.category))].sort(), @@ -110,9 +110,9 @@ export default function MarketplacePage() { const statusCounts = useMemo(() => ({ all: modules.length, - active: modules.filter((m) => activations.has(m.id)).length, - available: modules.filter((m) => !activations.has(m.id)).length, - }), [modules, activations]); + active: modules.filter((m) => m.isActiveForTenant).length, + available: modules.filter((m) => !m.isActiveForTenant).length, + }), [modules]); const hasFiltersApplied = searchQuery !== '' || statusFilter !== 'all' || categoryFilter !== 'all'; @@ -142,15 +142,13 @@ export default function MarketplacePage() { }); if (res.ok) { - setActivations((prev) => { - const next = new Map(prev); - if (currentlyActive) { - next.delete(moduleId); - } else { - next.set(moduleId, true); - } - return next; - }); + setModules((prev) => + prev.map((m) => + m.id === moduleId + ? { ...m, isActiveForTenant: !currentlyActive, hasAccess: !currentlyActive } + : m, + ), + ); bumpSidebarRefresh(); addToast('success', currentlyActive ? t('toastDeactivated') : t('toastActivated')); } else { @@ -172,13 +170,13 @@ export default function MarketplacePage() { } }; - if (!isAdmin) { - return ( -
-

{t('accessDenied')}

-
- ); - } + const handleOpenDetail = (slug: string) => { + router.push(`/marketplace/${slug}`); + }; + + const handleLockedClick = () => { + addToast('error', t('toastNoAccess')); + }; return (
@@ -244,9 +242,13 @@ export default function MarketplacePage() { description={mod.description} icon={mod.icon ?? undefined} category={mod.category} - isActive={activations.has(mod.id)} + isActive={mod.isActiveForTenant} + hasAccess={mod.hasAccess} + canManage={isAdmin} isToggling={toggling === mod.id} onToggle={handleToggle} + onOpenDetail={handleOpenDetail} + onLockedClick={handleLockedClick} /> ))}
diff --git a/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx b/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx index b0f3d51..d20ffd1 100644 --- a/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx +++ b/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx @@ -52,12 +52,19 @@ vi.mock('@/lib/stores/marketplace-store', () => ({ }), })); +// marketplace/page.tsx navigates to the detail page on card click (D-08) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + const mockTenants = [ { id: 't1', name: 'Tenant Alpha', slug: 'alpha', isActive: true, createdAt: '2026-01-01', userCount: 5 }, { id: 't2', name: 'Tenant Beta', slug: 'beta', isActive: true, createdAt: '2026-02-01', userCount: 3 }, ]; -const mockModules = [ +// GET /modules/catalog (Plan 15-03/15-08, D-08): full module record plus +// isActiveForTenant/hasAccess in one response. +const mockCatalogActive = [ { id: 'mod-1', slug: 'domaincheck', @@ -67,11 +74,11 @@ const mockModules = [ description: { de: 'Domain pruefen', en: 'Check domain' }, icon: 'Globe', isSystem: false, + isActiveForTenant: true, + hasAccess: true, }, ]; -const mockActiveModules = [mockModules[0]]; - afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -176,11 +183,8 @@ describe('ActivationDialog', () => { vi.stubGlobal( 'fetch', vi.fn((url: string) => { - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); }), @@ -213,11 +217,8 @@ describe('ActivationDialog', () => { if (url.includes('/deactivate')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); } - if (url.includes('/modules/active')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }); - } - if (url.includes('/modules')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) }); + if (url.includes('/modules/catalog')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); }),