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:
@@ -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('') });
|
||||
|
||||
@@ -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({}) });
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user