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