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',
|
id: 'mod-1',
|
||||||
slug: 'domaincheck',
|
slug: 'domaincheck',
|
||||||
@@ -64,6 +66,8 @@ const mockModules = [
|
|||||||
description: { de: 'Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs', en: 'Check domain availability' },
|
description: { de: 'Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs', en: 'Check domain availability' },
|
||||||
icon: 'Globe',
|
icon: 'Globe',
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mod-2',
|
id: 'mod-2',
|
||||||
@@ -74,10 +78,15 @@ const mockModules = [
|
|||||||
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
||||||
icon: null,
|
icon: null,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockActiveModules = [mockModules[0]];
|
const mockCatalogActive = [
|
||||||
|
{ ...mockCatalog[0], isActiveForTenant: true, hasAccess: true },
|
||||||
|
mockCatalog[1],
|
||||||
|
];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -104,11 +113,8 @@ describe('Module Detail Page', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
}),
|
}),
|
||||||
@@ -128,11 +134,8 @@ describe('Module Detail Page', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
}),
|
}),
|
||||||
@@ -152,11 +155,8 @@ describe('Module Detail Page', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
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';
|
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;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +25,8 @@ interface Module {
|
|||||||
description: Record<string, string>;
|
description: Record<string, string>;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
|
isActiveForTenant: boolean;
|
||||||
|
hasAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModuleDetailPage() {
|
export default function ModuleDetailPage() {
|
||||||
@@ -29,13 +36,14 @@ export default function ModuleDetailPage() {
|
|||||||
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
const [module, setModule] = useState<Module | null>(null);
|
const [module, setModule] = useState<CatalogModule | null>(null);
|
||||||
const [isActive, setIsActive] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
const [notFound, setNotFound] = useState(false);
|
const [notFound, setNotFound] = useState(false);
|
||||||
const [pendingDeactivation, setPendingDeactivation] = 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 isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
||||||
|
|
||||||
@@ -46,20 +54,13 @@ export default function ModuleDetailPage() {
|
|||||||
headers['x-tenant-id'] = selectedTenantId;
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [allRes, activeRes] = await Promise.all([
|
const res = await fetch(`${API_URL}/modules/catalog`, { credentials: 'include', headers });
|
||||||
fetch(`${API_URL}/modules`, { credentials: 'include' }),
|
|
||||||
fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (allRes.ok) {
|
if (res.ok) {
|
||||||
const allModules: Module[] = await allRes.json();
|
const catalog: CatalogModule[] = await res.json();
|
||||||
const found = allModules.find((m) => m.slug === slug);
|
const found = catalog.find((m) => m.slug === slug);
|
||||||
if (found) {
|
if (found) {
|
||||||
setModule(found);
|
setModule(found);
|
||||||
if (activeRes.ok) {
|
|
||||||
const activeModules: Module[] = await activeRes.json();
|
|
||||||
setIsActive(activeModules.some((m) => m.id === found.id));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setNotFound(true);
|
setNotFound(true);
|
||||||
}
|
}
|
||||||
@@ -72,15 +73,11 @@ export default function ModuleDetailPage() {
|
|||||||
}, [slug, selectedTenantId]);
|
}, [slug, selectedTenantId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) {
|
|
||||||
fetchModule();
|
fetchModule();
|
||||||
} else {
|
}, [fetchModule]);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAdmin, fetchModule]);
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isActive) {
|
if (module?.isActiveForTenant) {
|
||||||
setPendingDeactivation(true);
|
setPendingDeactivation(true);
|
||||||
} else {
|
} else {
|
||||||
performToggle();
|
performToggle();
|
||||||
@@ -91,7 +88,8 @@ export default function ModuleDetailPage() {
|
|||||||
if (!module) return;
|
if (!module) return;
|
||||||
setToggling(true);
|
setToggling(true);
|
||||||
try {
|
try {
|
||||||
const action = isActive ? 'deactivate' : 'activate';
|
const wasActive = module.isActiveForTenant;
|
||||||
|
const action = wasActive ? 'deactivate' : 'activate';
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (selectedTenantId) {
|
if (selectedTenantId) {
|
||||||
headers['x-tenant-id'] = selectedTenantId;
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
@@ -104,11 +102,13 @@ export default function ModuleDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsActive(!isActive);
|
setModule((prev) =>
|
||||||
|
prev ? { ...prev, isActiveForTenant: !wasActive, hasAccess: !wasActive } : prev,
|
||||||
|
);
|
||||||
bumpSidebarRefresh();
|
bumpSidebarRefresh();
|
||||||
addToast('success', isActive ? t('toastDeactivated') : t('toastActivated'));
|
addToast('success', wasActive ? t('toastDeactivated') : t('toastActivated'));
|
||||||
} else {
|
} else {
|
||||||
addToast('error', isActive ? t('toastDeactivateError') : t('toastActivateError'));
|
addToast('error', wasActive ? t('toastDeactivateError') : t('toastActivateError'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// 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) {
|
if (notFound || !module) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<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 localizedDescription = module.description[locale] || module.description.en || module.description.de || '';
|
||||||
|
const isActive = module.isActiveForTenant;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
@@ -192,6 +185,7 @@ export default function ModuleDetailPage() {
|
|||||||
<span className={`text-sm font-medium ${isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
|
<span className={`text-sm font-medium ${isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
|
||||||
{isActive ? t('detailStatusActive') : t('detailStatusInactive')}
|
{isActive ? t('detailStatusActive') : t('detailStatusInactive')}
|
||||||
</span>
|
</span>
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={toggling}
|
disabled={toggling}
|
||||||
@@ -209,6 +203,7 @@ export default function ModuleDetailPage() {
|
|||||||
t('activate')
|
t('activate')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActivationDialog
|
<ActivationDialog
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock('next-intl', () => ({
|
|||||||
deactivate: 'Deaktivieren',
|
deactivate: 'Deaktivieren',
|
||||||
statusActive: 'Aktiviert',
|
statusActive: 'Aktiviert',
|
||||||
statusAvailable: 'Verfuegbar',
|
statusAvailable: 'Verfuegbar',
|
||||||
|
statusNoAccess: 'Kein Zugriff',
|
||||||
};
|
};
|
||||||
return translations[key] ?? key;
|
return translations[key] ?? key;
|
||||||
},
|
},
|
||||||
@@ -25,8 +26,12 @@ const baseProps = {
|
|||||||
icon: 'Globe',
|
icon: 'Globe',
|
||||||
category: 'Domain-Tools',
|
category: 'Domain-Tools',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
hasAccess: true,
|
||||||
|
canManage: true,
|
||||||
isToggling: false,
|
isToggling: false,
|
||||||
onToggle: vi.fn(),
|
onToggle: vi.fn(),
|
||||||
|
onOpenDetail: vi.fn(),
|
||||||
|
onLockedClick: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -75,4 +80,87 @@ describe('MarketplaceCard', () => {
|
|||||||
expect(onToggle).toHaveBeenCalledOnce();
|
expect(onToggle).toHaveBeenCalledOnce();
|
||||||
expect(onToggle).toHaveBeenCalledWith('mod-1', false);
|
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;
|
icon?: string;
|
||||||
category: string;
|
category: string;
|
||||||
isActive: boolean;
|
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;
|
isToggling: boolean;
|
||||||
onToggle: (id: string, currentlyActive: boolean) => void;
|
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,
|
icon,
|
||||||
category,
|
category,
|
||||||
isActive,
|
isActive,
|
||||||
|
hasAccess,
|
||||||
|
canManage,
|
||||||
isToggling,
|
isToggling,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onOpenDetail,
|
||||||
|
onLockedClick,
|
||||||
}: MarketplaceCardProps) {
|
}: MarketplaceCardProps) {
|
||||||
const t = useTranslations('marketplace');
|
const t = useTranslations('marketplace');
|
||||||
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
|
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
|
||||||
const localizedDescription = description[locale] || description.en || description.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 (
|
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 */}
|
{/* Header: icon + name + badges */}
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
||||||
@@ -93,7 +129,7 @@ export function MarketplaceCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
|
<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">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{category}
|
{category}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,6 +143,17 @@ export function MarketplaceCard({
|
|||||||
>
|
>
|
||||||
{isActive ? t('statusActive') : t('statusAvailable')}
|
{isActive ? t('statusActive') : t('statusAvailable')}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,10 +163,14 @@ export function MarketplaceCard({
|
|||||||
{localizedDescription}
|
{localizedDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Footer: action button */}
|
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
||||||
|
{canManage && (
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(id, isActive)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(id, isActive);
|
||||||
|
}}
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
|
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
|
||||||
isActive
|
isActive
|
||||||
@@ -136,6 +187,7 @@ export function MarketplaceCard({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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',
|
id: 'mod-1',
|
||||||
slug: 'domaincheck',
|
slug: 'domaincheck',
|
||||||
@@ -57,6 +64,8 @@ const mockModules = [
|
|||||||
description: { de: 'Domain pruefen', en: 'Check domain' },
|
description: { de: 'Domain pruefen', en: 'Check domain' },
|
||||||
icon: 'Globe',
|
icon: 'Globe',
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: true, // Domaincheck is active
|
||||||
|
hasAccess: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mod-2',
|
id: 'mod-2',
|
||||||
@@ -67,6 +76,8 @@ const mockModules = [
|
|||||||
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
||||||
icon: null,
|
icon: null,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mod-3',
|
id: 'mod-3',
|
||||||
@@ -77,22 +88,17 @@ const mockModules = [
|
|||||||
description: { de: 'E-Mails verwalten', en: 'Manage emails' },
|
description: { de: 'E-Mails verwalten', en: 'Manage emails' },
|
||||||
icon: null,
|
icon: null,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockActiveModules = [
|
|
||||||
mockModules[0], // Domaincheck is active
|
|
||||||
];
|
|
||||||
|
|
||||||
function stubFetch() {
|
function stubFetch() {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
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',
|
id: 'mod-1',
|
||||||
slug: 'domaincheck',
|
slug: 'domaincheck',
|
||||||
@@ -45,6 +52,8 @@ const mockModules = [
|
|||||||
description: { de: 'Domain pruefen', en: 'Check domain' },
|
description: { de: 'Domain pruefen', en: 'Check domain' },
|
||||||
icon: 'Globe',
|
icon: 'Globe',
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mod-2',
|
id: 'mod-2',
|
||||||
@@ -55,20 +64,14 @@ const mockModules = [
|
|||||||
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
||||||
icon: null,
|
icon: null,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: false,
|
||||||
|
hasAccess: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockActiveModules = [
|
const mockCatalogOneActive = [
|
||||||
{
|
{ ...mockCatalog[0], isActiveForTenant: true, hasAccess: true },
|
||||||
id: 'mod-1',
|
mockCatalog[1],
|
||||||
slug: 'domaincheck',
|
|
||||||
name: 'Domaincheck',
|
|
||||||
version: '1.0.0',
|
|
||||||
category: 'Domain-Tools',
|
|
||||||
description: { de: 'Domain pruefen', en: 'Check domain' },
|
|
||||||
icon: 'Globe',
|
|
||||||
isSystem: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,11 +99,8 @@ describe('MarketplacePage', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalog) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
}),
|
}),
|
||||||
@@ -115,15 +115,12 @@ describe('MarketplacePage', () => {
|
|||||||
expect(screen.getByText('Converter')).toBeInTheDocument();
|
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(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogOneActive) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
}),
|
}),
|
||||||
@@ -143,28 +140,36 @@ describe('MarketplacePage', () => {
|
|||||||
expect(texts).toContain('Verfuegbar');
|
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) =>
|
mockAuthStore.mockImplementation((selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
user: { id: 'u2', username: 'user', displayName: 'User', role: 'USER', tenantId: 't1' },
|
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();
|
const Page = await importPage();
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
expect(screen.getByText('Zugriff verweigert')).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
|
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 () => {
|
it('renders empty-state heading when no modules are available', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
import { MarketplaceCard } from './components/MarketplaceCard';
|
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';
|
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;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,16 +32,18 @@ interface Module {
|
|||||||
description: Record<string, string>;
|
description: Record<string, string>;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
|
isActiveForTenant: boolean;
|
||||||
|
hasAccess: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarketplacePage() {
|
export default function MarketplacePage() {
|
||||||
const t = useTranslations('marketplace');
|
const t = useTranslations('marketplace');
|
||||||
|
const router = useRouter();
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<CatalogModule[]>([]);
|
||||||
const [activations, setActivations] = useState<Map<string, boolean>>(new Map());
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [toggling, setToggling] = useState<string | null>(null);
|
const [toggling, setToggling] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
const [pendingDeactivation, setPendingDeactivation] = useState<{ id: string; name: string } | null>(null);
|
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 isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const fetchModules = useCallback(async () => {
|
const fetchModules = useCallback(async () => {
|
||||||
@@ -51,23 +65,13 @@ export default function MarketplacePage() {
|
|||||||
headers['x-tenant-id'] = selectedTenantId;
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [allRes, activeRes] = await Promise.all([
|
const res = await fetch(`${API_URL}/modules/catalog`, { credentials: 'include', headers });
|
||||||
fetch(`${API_URL}/modules`, { credentials: 'include' }),
|
|
||||||
fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (allRes.ok) {
|
if (res.ok) {
|
||||||
const allModules: Module[] = await allRes.json();
|
const catalog: CatalogModule[] = await res.json();
|
||||||
setModules(allModules);
|
setModules(catalog);
|
||||||
}
|
} else {
|
||||||
|
setError(t('errorLoad'));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(t('errorLoad'));
|
setError(t('errorLoad'));
|
||||||
@@ -77,12 +81,8 @@ export default function MarketplacePage() {
|
|||||||
}, [selectedTenantId, t]);
|
}, [selectedTenantId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) {
|
|
||||||
fetchModules();
|
fetchModules();
|
||||||
} else {
|
}, [fetchModules]);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAdmin, fetchModules]);
|
|
||||||
|
|
||||||
|
|
||||||
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
||||||
@@ -96,12 +96,12 @@ export default function MarketplacePage() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (statusFilter === 'active' && !activations.has(mod.id)) return false;
|
if (statusFilter === 'active' && !mod.isActiveForTenant) return false;
|
||||||
if (statusFilter === 'available' && activations.has(mod.id)) return false;
|
if (statusFilter === 'available' && mod.isActiveForTenant) return false;
|
||||||
if (categoryFilter !== 'all' && mod.category !== categoryFilter) return false;
|
if (categoryFilter !== 'all' && mod.category !== categoryFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [modules, activations, searchQuery, statusFilter, categoryFilter, locale]);
|
}, [modules, searchQuery, statusFilter, categoryFilter, locale]);
|
||||||
|
|
||||||
const categories = useMemo(
|
const categories = useMemo(
|
||||||
() => [...new Set(modules.map((m) => m.category))].sort(),
|
() => [...new Set(modules.map((m) => m.category))].sort(),
|
||||||
@@ -110,9 +110,9 @@ export default function MarketplacePage() {
|
|||||||
|
|
||||||
const statusCounts = useMemo(() => ({
|
const statusCounts = useMemo(() => ({
|
||||||
all: modules.length,
|
all: modules.length,
|
||||||
active: modules.filter((m) => activations.has(m.id)).length,
|
active: modules.filter((m) => m.isActiveForTenant).length,
|
||||||
available: modules.filter((m) => !activations.has(m.id)).length,
|
available: modules.filter((m) => !m.isActiveForTenant).length,
|
||||||
}), [modules, activations]);
|
}), [modules]);
|
||||||
|
|
||||||
const hasFiltersApplied = searchQuery !== '' || statusFilter !== 'all' || categoryFilter !== 'all';
|
const hasFiltersApplied = searchQuery !== '' || statusFilter !== 'all' || categoryFilter !== 'all';
|
||||||
|
|
||||||
@@ -142,15 +142,13 @@ export default function MarketplacePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setActivations((prev) => {
|
setModules((prev) =>
|
||||||
const next = new Map(prev);
|
prev.map((m) =>
|
||||||
if (currentlyActive) {
|
m.id === moduleId
|
||||||
next.delete(moduleId);
|
? { ...m, isActiveForTenant: !currentlyActive, hasAccess: !currentlyActive }
|
||||||
} else {
|
: m,
|
||||||
next.set(moduleId, true);
|
),
|
||||||
}
|
);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
bumpSidebarRefresh();
|
bumpSidebarRefresh();
|
||||||
addToast('success', currentlyActive ? t('toastDeactivated') : t('toastActivated'));
|
addToast('success', currentlyActive ? t('toastDeactivated') : t('toastActivated'));
|
||||||
} else {
|
} else {
|
||||||
@@ -172,13 +170,13 @@ export default function MarketplacePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
const handleOpenDetail = (slug: string) => {
|
||||||
return (
|
router.push(`/marketplace/${slug}`);
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
};
|
||||||
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
|
|
||||||
</div>
|
const handleLockedClick = () => {
|
||||||
);
|
addToast('error', t('toastNoAccess'));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -244,9 +242,13 @@ export default function MarketplacePage() {
|
|||||||
description={mod.description}
|
description={mod.description}
|
||||||
icon={mod.icon ?? undefined}
|
icon={mod.icon ?? undefined}
|
||||||
category={mod.category}
|
category={mod.category}
|
||||||
isActive={activations.has(mod.id)}
|
isActive={mod.isActiveForTenant}
|
||||||
|
hasAccess={mod.hasAccess}
|
||||||
|
canManage={isAdmin}
|
||||||
isToggling={toggling === mod.id}
|
isToggling={toggling === mod.id}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
|
onOpenDetail={handleOpenDetail}
|
||||||
|
onLockedClick={handleLockedClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 = [
|
const mockTenants = [
|
||||||
{ id: 't1', name: 'Tenant Alpha', slug: 'alpha', isActive: true, createdAt: '2026-01-01', userCount: 5 },
|
{ 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 },
|
{ 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',
|
id: 'mod-1',
|
||||||
slug: 'domaincheck',
|
slug: 'domaincheck',
|
||||||
@@ -67,11 +74,11 @@ const mockModules = [
|
|||||||
description: { de: 'Domain pruefen', en: 'Check domain' },
|
description: { de: 'Domain pruefen', en: 'Check domain' },
|
||||||
icon: 'Globe',
|
icon: 'Globe',
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isActiveForTenant: true,
|
||||||
|
hasAccess: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockActiveModules = [mockModules[0]];
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -176,11 +183,8 @@ describe('ActivationDialog', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
}),
|
}),
|
||||||
@@ -213,11 +217,8 @@ describe('ActivationDialog', () => {
|
|||||||
if (url.includes('/deactivate')) {
|
if (url.includes('/deactivate')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
}
|
}
|
||||||
if (url.includes('/modules/active')) {
|
if (url.includes('/modules/catalog')) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockCatalogActive) });
|
||||||
}
|
|
||||||
if (url.includes('/modules')) {
|
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
|
||||||
}
|
}
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user