From 0f3379f2637a77554b4657445672dddc271285c0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 23 Jun 2026 09:23:04 +0200 Subject: [PATCH] feat(04-02): add TenantContextSelector, ActivationDialog, and asymmetric toggle UX Super-Admin tenant dropdown fetches /tenants, sets selectedTenantId for per-tenant activation. Deactivation gated by confirmation dialog; activation immediate with toast. 18 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- .../components/ActivationDialog.tsx | 72 +++++ .../components/TenantContextSelector.tsx | 62 ++++ .../web/src/app/(portal)/marketplace/page.tsx | 33 ++- .../marketplace/tenant-selector.test.tsx | 270 ++++++++++++++++++ 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx create mode 100644 apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx create mode 100644 apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx diff --git a/apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx b/apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx new file mode 100644 index 0000000..7c585de --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; + +interface ActivationDialogProps { + open: boolean; + moduleName: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: ActivationDialogProps) { + const t = useTranslations('marketplace'); + const cancelRef = useRef(null); + const confirmRef = useRef(null); + + useEffect(() => { + if (open) { + cancelRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + return; + } + if (e.key === 'Tab') { + e.preventDefault(); + if (document.activeElement === cancelRef.current) { + confirmRef.current?.focus(); + } else { + cancelRef.current?.focus(); + } + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+
+

{t('deactivateDialogTitle')}

+

+ {t('deactivateDialogBody', { moduleName })} +

+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx b/apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx new file mode 100644 index 0000000..2c7662e --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface Tenant { + id: string; + name: string; + slug: string; + isActive: boolean; + createdAt: string; + userCount: number; +} + +export function TenantContextSelector() { + const t = useTranslations('marketplace'); + const currentUser = useAuthStore((s) => s.user); + const { setSelectedTenantId } = useMarketplaceStore(); + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + + const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'; + + useEffect(() => { + if (!isSuperAdmin) return; + fetch(`${API_URL}/tenants`, { credentials: 'include' }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: Tenant[]) => setTenants(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [isSuperAdmin]); + + if (!isSuperAdmin) return null; + + return ( +
+ + {loading ? ( + Laden... + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/marketplace/page.tsx b/apps/web/src/app/(portal)/marketplace/page.tsx index c81b008..38c0df9 100644 --- a/apps/web/src/app/(portal)/marketplace/page.tsx +++ b/apps/web/src/app/(portal)/marketplace/page.tsx @@ -9,6 +9,8 @@ import { MarketplaceSearch } from './components/MarketplaceSearch'; import { StatusFilter, type StatusValue } from './components/StatusFilter'; import { CategoryFilter } from './components/CategoryFilter'; import { ToastContainer, useToastStore } from './components/Toast'; +import { TenantContextSelector } from './components/TenantContextSelector'; +import { ActivationDialog } from './components/ActivationDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -38,6 +40,7 @@ export default function MarketplacePage() { const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); + const [pendingDeactivation, setPendingDeactivation] = useState<{ id: string; name: string } | null>(null); const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; @@ -81,6 +84,7 @@ export default function MarketplacePage() { } }, [isAdmin, fetchModules]); + const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de'; const filteredModules = useMemo(() => { @@ -112,7 +116,16 @@ export default function MarketplacePage() { const hasFiltersApplied = searchQuery !== '' || statusFilter !== 'all' || categoryFilter !== 'all'; - const toggleModule = async (moduleId: string, currentlyActive: boolean) => { + const handleToggle = (moduleId: string, currentlyActive: boolean) => { + if (currentlyActive) { + const mod = modules.find((m) => m.id === moduleId); + setPendingDeactivation({ id: moduleId, name: mod?.name ?? '' }); + } else { + performToggle(moduleId, false); + } + }; + + const performToggle = async (moduleId: string, currentlyActive: boolean) => { setToggling(moduleId); setError(null); try { @@ -152,6 +165,13 @@ export default function MarketplacePage() { } }; + const confirmDeactivation = () => { + if (pendingDeactivation) { + performToggle(pendingDeactivation.id, true); + setPendingDeactivation(null); + } + }; + if (!isAdmin) { return (
@@ -162,6 +182,8 @@ export default function MarketplacePage() { return (
+ +

{t('title')}

{t('subtitle')}

@@ -224,7 +246,7 @@ export default function MarketplacePage() { category={mod.category} isActive={activations.has(mod.id)} isToggling={toggling === mod.id} - onToggle={toggleModule} + onToggle={handleToggle} /> ))}
@@ -233,6 +255,13 @@ export default function MarketplacePage() { )} + + setPendingDeactivation(null)} + />
); } diff --git a/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx b/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx new file mode 100644 index 0000000..b0f3d51 --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/tenant-selector.test.tsx @@ -0,0 +1,270 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + title: 'Marktplatz', + subtitle: 'Entdecken und aktivieren Sie Module fuer Ihren Mandanten', + activate: 'Modul aktivieren', + deactivate: 'Deaktivieren', + searchPlaceholder: 'Module suchen...', + statusAll: 'Alle', + statusActive: 'Aktiviert', + statusAvailable: 'Verfuegbar', + categoryAll: 'Alle', + emptyTitle: 'Noch keine Module verfuegbar', + filteredEmptyTitle: 'Keine Ergebnisse', + errorLoad: 'Module konnten nicht geladen werden.', + toastActivated: 'Modul erfolgreich aktiviert', + toastDeactivated: 'Modul erfolgreich deaktiviert', + accessDenied: 'Zugriff verweigert', + tenantContextLabel: 'Mandanten-Kontext', + deactivateDialogTitle: 'Modul deaktivieren', + deactivateDialogBody: 'Moechten Sie {moduleName} fuer diesen Mandanten deaktivieren?', + deactivateDialogConfirm: 'Deaktivieren', + deactivateDialogCancel: 'Abbrechen', + }; + let result = translations[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, v); + } + } + return result; + }, +})); + +const mockAuthStore = vi.fn(); +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector), +})); + +const mockSetSelectedTenantId = vi.fn(); +const mockBumpSidebarRefresh = vi.fn(); +vi.mock('@/lib/stores/marketplace-store', () => ({ + useMarketplaceStore: () => ({ + sidebarRefreshKey: 0, + bumpSidebarRefresh: mockBumpSidebarRefresh, + selectedTenantId: null, + setSelectedTenantId: mockSetSelectedTenantId, + }), +})); + +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 = [ + { + 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 mockActiveModules = [mockModules[0]]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mockSetSelectedTenantId.mockClear(); + mockBumpSidebarRefresh.mockClear(); +}); + +async function importPage() { + const mod = await import('./page'); + return mod.default; +} + +async function importTenantSelector() { + const mod = await import('./components/TenantContextSelector'); + return mod.TenantContextSelector; +} + +describe('TenantContextSelector', () => { + it('renders tenant options for SUPER_ADMIN; renders nothing for ADMIN', async () => { + // SUPER_ADMIN case + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) => + selector({ + user: { id: 'u1', username: 'superadmin', displayName: 'Super', role: 'SUPER_ADMIN', tenantId: 't1' }, + }), + ); + + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url.includes('/tenants')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockTenants) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + }), + ); + + const TenantContextSelector = await importTenantSelector(); + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByText('Tenant Alpha')).toBeInTheDocument(); + }); + expect(screen.getByText('Tenant Beta')).toBeInTheDocument(); + unmount(); + + // ADMIN case + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) => + selector({ + user: { id: 'u2', username: 'admin', displayName: 'Admin', role: 'ADMIN', tenantId: 't1' }, + }), + ); + + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('calls setSelectedTenantId when selector value changes', async () => { + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) => + selector({ + user: { id: 'u1', username: 'superadmin', displayName: 'Super', role: 'SUPER_ADMIN', tenantId: 't1' }, + }), + ); + + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url.includes('/tenants')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(mockTenants) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + }), + ); + + const TenantContextSelector = await importTenantSelector(); + render(); + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + const select = screen.getByRole('combobox'); + await userEvent.selectOptions(select, 't2'); + + expect(mockSetSelectedTenantId).toHaveBeenCalledWith('t2'); + }); +}); + +describe('ActivationDialog', () => { + beforeEach(() => { + mockAuthStore.mockImplementation( + (selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) => + selector({ + user: { id: 'u1', username: 'admin', displayName: 'Admin', role: 'ADMIN', tenantId: 't1' }, + }), + ); + }); + + it('clicking deactivate opens dialog with module name, does NOT call API', 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) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }), + ); + + const Page = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + + const deactivateBtn = screen.getByText('Deaktivieren'); + await userEvent.click(deactivateBtn); + + await waitFor(() => { + expect(screen.getByText(/Moechten Sie Domaincheck/)).toBeInTheDocument(); + }); + + // No deactivate API call yet + const fetchCalls = (globalThis.fetch as ReturnType).mock.calls; + const deactivateCalls = fetchCalls.filter((c: string[]) => c[0]?.includes('/deactivate')); + expect(deactivateCalls).toHaveLength(0); + }); + + it('confirming dialog calls deactivate; cancelling closes dialog without API call', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + 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) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }), + ); + + const Page = await importPage(); + render(); + + await waitFor(() => { + expect(screen.getByText('Domaincheck')).toBeInTheDocument(); + }); + + // Open dialog + const deactivateBtn = screen.getByText('Deaktivieren'); + await userEvent.click(deactivateBtn); + + await waitFor(() => { + expect(screen.getByText(/Moechten Sie Domaincheck/)).toBeInTheDocument(); + }); + + // Cancel + const cancelBtn = screen.getByText('Abbrechen'); + await userEvent.click(cancelBtn); + + await waitFor(() => { + expect(screen.queryByText(/Moechten Sie Domaincheck/)).not.toBeInTheDocument(); + }); + + // Re-open and confirm + await userEvent.click(screen.getByText('Deaktivieren')); + + await waitFor(() => { + expect(screen.getByText(/Moechten Sie Domaincheck/)).toBeInTheDocument(); + }); + + const dialogConfirmBtns = screen.getAllByText('Deaktivieren'); + const dialogConfirm = dialogConfirmBtns.find( + (el) => el.closest('[role="dialog"]') !== null, + ); + expect(dialogConfirm).toBeDefined(); + await userEvent.click(dialogConfirm!); + + // Verify deactivate API was called + await waitFor(() => { + const fetchCalls = (globalThis.fetch as ReturnType).mock.calls; + const deactivateCalls = fetchCalls.filter((c: string[]) => c[0]?.includes('/deactivate')); + expect(deactivateCalls.length).toBeGreaterThan(0); + }); + }); +});