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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:23:04 +02:00
parent 06fb52da82
commit 0f3379f263
4 changed files with 435 additions and 2 deletions
@@ -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<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
<div className="max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('deactivateDialogTitle')}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('deactivateDialogBody', { moduleName })}
</p>
<div className="flex justify-end gap-3">
<button
ref={cancelRef}
onClick={onCancel}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
>
{t('deactivateDialogCancel')}
</button>
<button
ref={confirmRef}
onClick={onConfirm}
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{t('deactivateDialogConfirm')}
</button>
</div>
</div>
</div>
);
}
@@ -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<Tenant[]>([]);
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 (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border">
<label className="block text-xs font-semibold text-muted-foreground mb-1.5">
{t('tenantContextLabel')}
</label>
{loading ? (
<span className="text-sm text-muted-foreground">Laden...</span>
) : (
<select
onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue=""
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]"
>
<option value="">—</option>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name}
</option>
))}
</select>
)}
</div>
);
}
+31 -2
View File
@@ -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<StatusValue>('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 (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -162,6 +182,8 @@ export default function MarketplacePage() {
return (
<div className="space-y-6">
<TenantContextSelector />
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
@@ -224,7 +246,7 @@ export default function MarketplacePage() {
category={mod.category}
isActive={activations.has(mod.id)}
isToggling={toggling === mod.id}
onToggle={toggleModule}
onToggle={handleToggle}
/>
))}
</div>
@@ -233,6 +255,13 @@ export default function MarketplacePage() {
)}
<ToastContainer />
<ActivationDialog
open={pendingDeactivation !== null}
moduleName={pendingDeactivation?.name ?? ''}
onConfirm={confirmDeactivation}
onCancel={() => setPendingDeactivation(null)}
/>
</div>
);
}
@@ -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<string, string>) => {
const translations: Record<string, string> = {
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(<TenantContextSelector />);
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(<TenantContextSelector />);
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(<TenantContextSelector />);
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(<Page />);
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<typeof vi.fn>).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(<Page />);
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<typeof vi.fn>).mock.calls;
const deactivateCalls = fetchCalls.filter((c: string[]) => c[0]?.includes('/deactivate'));
expect(deactivateCalls.length).toBeGreaterThan(0);
});
});
});