From 3cd6d997cf1919715b0c094afdef550f2d85ad12 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:21:25 +0200 Subject: [PATCH] feat(15-07): Aktivierungsdialog mit drei Aktionen in /admin/modules - Neue ActivateModuleDialog.tsx: Abbrechen / "Spaeter konfigurieren" (nur POST /modules/:id/activate) / "Sofort freigeben" (POST .../activate gefolgt von POST /module-grants fuer die als Standard markierte Gruppe) -- zwei getrennte Aufrufe, kein neuer kombinierter Endpoint (D-10) - Laedt GET /groups beim Oeffnen; ohne markierte Standardgruppe ist "Sofort freigeben" disabled mit Hinweistext (D-13) - admin/modules/page.tsx: toggleModule-Klick verzweigt -- Deaktivierung bleibt direkt, Aktivierung oeffnet den Dialog statt sofort zu aktivieren; Erfolg aktualisiert Aktivierungs-Map + Sidebar-Refresh wie bisher - grants-matrix.test.tsx erweitert um 3 Tests fuer den Dialog (alle drei Buttons vorhanden, Sofort-freigeben deaktiviert ohne Standardgruppe, Aufrufreihenfolge activate->grant) --- .../components/ActivateModuleDialog.tsx | 179 ++++++++++++++++++ .../modules/grants/grants-matrix.test.tsx | 110 +++++++++++ .../src/app/(portal)/admin/modules/page.tsx | 35 +++- 3 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx diff --git a/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx b/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx new file mode 100644 index 0000000..ddd5dc1 --- /dev/null +++ b/apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface Group { + id: string; + name: string; + isDefault: boolean; +} + +interface ActivateModuleDialogProps { + open: boolean; + moduleId: string; + moduleName: string; + onCancel: () => void; + onSuccess: (moduleId: string) => void; + onError: (message: string) => void; +} + +/** + * Rückfrage beim Aktivieren eines Moduls (D-10, Surface Contract 6). + * + * Lädt beim Öffnen GET /groups und sucht die als Standard markierte Gruppe + * (D-13). Drei Aktionen: Abbrechen (kein Request), "Später konfigurieren" + * (nur POST /modules/:id/activate) und "Sofort freigeben" (POST + * .../activate gefolgt von POST /module-grants) -- zwei getrennte Aufrufe, + * kein neuer kombinierter Endpoint. Ohne markierte Standardgruppe ist + * "Sofort freigeben" deaktiviert und ein Hinweistext erklärt warum. + */ +export function ActivateModuleDialog({ + open, + moduleId, + moduleName, + onCancel, + onSuccess, + onError, +}: ActivateModuleDialogProps) { + const t = useTranslations('adminModules.activationDialog'); + + const [loadingGroups, setLoadingGroups] = useState(true); + const [defaultGroup, setDefaultGroup] = useState(null); + const [submitting, setSubmitting] = useState<'configureLater' | 'grantNow' | null>(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoadingGroups(true); + fetch(`${API_URL}/groups`, { credentials: 'include' }) + .then((res) => (res.ok ? res.json() : Promise.reject(res))) + .then((data: Group[]) => { + if (!cancelled) { + setDefaultGroup(data.find((g) => g.isDefault) ?? null); + } + }) + .catch(() => { + if (!cancelled) setDefaultGroup(null); + }) + .finally(() => { + if (!cancelled) setLoadingGroups(false); + }); + return () => { + cancelled = true; + }; + }, [open, moduleId]); + + if (!open) return null; + + const handleConfigureLater = async () => { + setSubmitting('configureLater'); + try { + const res = await fetch(`${API_URL}/modules/${moduleId}/activate`, { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + onSuccess(moduleId); + } else { + const body = await res.text().catch(() => ''); + onError(`${res.status}: ${body}`); + onCancel(); + } + } catch (err) { + onError(String(err)); + onCancel(); + } finally { + setSubmitting(null); + } + }; + + const handleGrantNow = async () => { + if (!defaultGroup) return; + setSubmitting('grantNow'); + try { + const activateRes = await fetch(`${API_URL}/modules/${moduleId}/activate`, { + method: 'POST', + credentials: 'include', + }); + if (!activateRes.ok) { + const body = await activateRes.text().catch(() => ''); + onError(`${activateRes.status}: ${body}`); + onCancel(); + return; + } + + // The module is genuinely active at this point regardless of what + // happens to the grant call below -- update the caller now so its + // state can never lag behind the server. + onSuccess(moduleId); + + const grantRes = await fetch(`${API_URL}/module-grants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ moduleId, groupId: defaultGroup.id }), + }); + if (!grantRes.ok) { + const body = await grantRes.text().catch(() => ''); + onError(`${grantRes.status}: ${body}`); + } + } catch (err) { + onError(String(err)); + onCancel(); + } finally { + setSubmitting(null); + } + }; + + const isSubmitting = submitting !== null; + const grantNowDisabled = isSubmitting || loadingGroups || !defaultGroup; + + return ( +
+
+

+ {t('title', { module: moduleName })} +

+

{t('body')}

+ + {!loadingGroups && !defaultGroup && ( +

{t('noDefaultGroupHint')}

+ )} + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx index c36caa6..f998f98 100644 --- a/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx +++ b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx @@ -22,6 +22,15 @@ const messages: Record> = { 'admin.groups.grants': { matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', }, + 'adminModules.activationDialog': { + title: 'Modul aktivieren: {module}', + body: 'Soll die Standardgruppe sofort Zugriff auf dieses Modul erhalten, oder möchtest du die Freigaben separat konfigurieren?', + grantNow: 'Sofort freigeben', + configureLater: 'Später konfigurieren', + cancel: 'Abbrechen', + noDefaultGroupHint: + 'Keine Standardgruppe markiert — lege zuerst eine unter Gruppen fest oder konfiguriere die Freigabe manuell.', + }, common: { loading: 'Laden...', accessDenied: 'Zugriff verweigert', @@ -67,6 +76,7 @@ vi.mock('@/lib/stores/auth-store', () => ({ })); import AdminModuleGrantsPage from './page'; +import { ActivateModuleDialog } from '../components/ActivateModuleDialog'; function stubAdmin() { mockAuthStore.mockImplementation( @@ -212,3 +222,103 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => { } }); }); + +describe('ActivateModuleDialog', () => { + it('renders all three actions (cancel, configure later, grant now)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ id: 'g1', name: 'Alle Benutzer', isDefault: true }]), + }), + ), + ); + + render( + , + ); + + expect(await screen.findByText('Abbrechen')).toBeInTheDocument(); + expect(screen.getByText('Später konfigurieren')).toBeInTheDocument(); + expect(screen.getByText('Sofort freigeben')).toBeInTheDocument(); + }); + + it('disables "Sofort freigeben" and shows the hint when no default group is marked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ id: 'g1', name: 'Alle Benutzer', isDefault: false }]), + }), + ), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText(/Keine Standardgruppe markiert/)).toBeInTheDocument(); + }); + expect(screen.getByText('Sofort freigeben')).toBeDisabled(); + }); + + it('triggers activate then module-grants (in order) when "Sofort freigeben" is clicked', async () => { + const calls: string[] = []; + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (typeof url === 'string' && url.endsWith('/groups')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{ id: 'g1', name: 'Alle Benutzer', isDefault: true }]), + }); + } + if (typeof url === 'string' && url.endsWith('/modules/m1/activate')) { + calls.push('activate'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + if (typeof url === 'string' && url.endsWith('/module-grants') && init?.method === 'POST') { + calls.push('grant'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); + vi.stubGlobal('fetch', fetchMock); + + const onSuccess = vi.fn(); + render( + , + ); + + const grantNowButton = await screen.findByText('Sofort freigeben'); + await waitFor(() => expect(grantNowButton).not.toBeDisabled()); + await userEvent.click(grantNowButton); + + await waitFor(() => { + expect(calls).toEqual(['activate', 'grant']); + }); + expect(onSuccess).toHaveBeenCalledWith('m1'); + }); +}); diff --git a/apps/web/src/app/(portal)/admin/modules/page.tsx b/apps/web/src/app/(portal)/admin/modules/page.tsx index bb91028..8eb6344 100644 --- a/apps/web/src/app/(portal)/admin/modules/page.tsx +++ b/apps/web/src/app/(portal)/admin/modules/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useAuthStore } from '@/lib/stores/auth-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; +import { ActivateModuleDialog } from './components/ActivateModuleDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -40,6 +41,10 @@ export default function AdminModulesPage() { const [loading, setLoading] = useState(true); const [toggling, setToggling] = useState(null); const [error, setError] = useState(null); + // Deactivation stays on the direct toggleModule path below. Activation + // instead opens this dialog (D-10) -- the module named here is the one + // whose "Aktivieren"-switch was just clicked. + const [activateDialogModule, setActivateDialogModule] = useState(null); const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; @@ -192,7 +197,16 @@ export default function AdminModulesPage() { {isActive ? t('activated') : t('deactivated')}