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
This commit is contained in:
2026-08-04 19:49:46 +02:00
parent 43c7fa200b
commit 2e7daa481e
8 changed files with 339 additions and 190 deletions
@@ -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('') });
}),
@@ -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<string, string>;
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<Module | null>(null);
const [isActive, setIsActive] = useState(false);
const [module, setModule] = useState<CatalogModule | null>(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<string, string> = {};
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 (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
</div>
);
}
if (notFound || !module) {
return (
<div className="max-w-2xl mx-auto space-y-4">
@@ -153,6 +145,7 @@ export default function ModuleDetailPage() {
}
const localizedDescription = module.description[locale] || module.description.en || module.description.de || '';
const isActive = module.isActiveForTenant;
return (
<div className="max-w-2xl mx-auto space-y-6">
@@ -192,23 +185,25 @@ export default function ModuleDetailPage() {
<span className={`text-sm font-medium ${isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
{isActive ? t('detailStatusActive') : t('detailStatusInactive')}
</span>
<button
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
>
{toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isActive ? (
t('deactivate')
) : (
t('activate')
)}
</button>
{isAdmin && (
<button
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
>
{toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isActive ? (
t('deactivate')
) : (
t('activate')
)}
</button>
)}
</div>
<ActivationDialog
@@ -10,6 +10,7 @@ vi.mock('next-intl', () => ({
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(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={false}
onOpenDetail={onOpenDetail}
onLockedClick={onLockedClick}
/>,
);
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(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={true}
onOpenDetail={onOpenDetail}
onLockedClick={onLockedClick}
/>,
);
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(
<MarketplaceCard
{...baseProps}
isActive={false}
hasAccess={false}
onOpenDetail={onOpenDetail}
onLockedClick={onLockedClick}
/>,
);
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(<MarketplaceCard {...baseProps} isActive={true} hasAccess={false} />);
const statusBadge = screen.getByRole('status');
expect(statusBadge.parentElement).toHaveClass('flex-wrap');
});
it('hides the activate/deactivate button when canManage is false', () => {
render(<MarketplaceCard {...baseProps} isActive={false} canManage={false} />);
expect(screen.queryByRole('button', { name: 'Modul aktivieren' })).not.toBeInTheDocument();
});
});
@@ -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 (
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30" style={{ minHeight: '180px' }}>
<div
className={`rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked
? 'opacity-60 cursor-not-allowed'
: isActive
? 'hover:shadow-md hover:border-primary/30 cursor-pointer'
: 'hover:shadow-md hover:border-primary/30'
}`}
style={{ minHeight: '180px' }}
onClick={isActive ? handleCardClick : undefined}
>
{/* Header: icon + name + badges */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
@@ -93,7 +129,7 @@ export function MarketplaceCard({
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
<div className="flex items-center gap-2 mt-1">
<div className="flex flex-wrap items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{category}
</span>
@@ -107,6 +143,17 @@ export function MarketplaceCard({
>
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
{locked && (
<span
className={[
'rounded-full px-2 py-0.5 text-xs',
'bg-amber-100 text-amber-700',
'dark:bg-amber-900/30 dark:text-amber-400',
].join(' ')}
>
{t('statusNoAccess')}
</span>
)}
</div>
</div>
</div>
@@ -116,26 +163,31 @@ export function MarketplaceCard({
{localizedDescription}
</p>
{/* Footer: action button */}
<div className="flex items-center justify-end">
<button
onClick={() => onToggle(id, isActive)}
disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
>
{isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isActive ? (
t('deactivate')
) : (
t('activate')
)}
</button>
</div>
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && (
<div className="flex items-center justify-end">
<button
onClick={(e) => {
e.stopPropagation();
onToggle(id, isActive);
}}
disabled={isToggling}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
>
{isToggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isActive ? (
t('deactivate')
) : (
t('activate')
)}
</button>
</div>
)}
</div>
);
}
@@ -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('') });
}),
@@ -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(<Page />);
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('') });
+50 -48
View File
@@ -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<string, string>;
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<Module[]>([]);
const [activations, setActivations] = useState<Map<string, boolean>>(new Map());
const [modules, setModules] = useState<CatalogModule[]>([]);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string, boolean>();
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 (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
</div>
);
}
const handleOpenDetail = (slug: string) => {
router.push(`/marketplace/${slug}`);
};
const handleLockedClick = () => {
addToast('error', t('toastNoAccess'));
};
return (
<div className="space-y-6">
@@ -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}
/>
))}
</div>
@@ -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({}) });
}),