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)
This commit is contained in:
2026-08-04 19:21:25 +02:00
parent 6b2a6f1ce4
commit 3cd6d997cf
3 changed files with 323 additions and 1 deletions
@@ -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<Group | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-modal="true"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="mb-2 break-words text-lg font-semibold text-foreground">
{t('title', { module: moduleName })}
</h2>
<p className="mb-4 break-words text-sm text-muted-foreground">{t('body')}</p>
{!loadingGroups && !defaultGroup && (
<p className="mb-4 text-xs text-muted-foreground">{t('noDefaultGroupHint')}</p>
)}
<div className="flex flex-wrap justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{t('cancel')}
</button>
<button
type="button"
onClick={handleConfigureLater}
disabled={isSubmitting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{t('configureLater')}
</button>
<button
type="button"
onClick={handleGrantNow}
disabled={grantNowDisabled}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
>
{t('grantNow')}
</button>
</div>
</div>
</div>
);
}
@@ -22,6 +22,15 @@ const messages: Record<string, Record<string, unknown>> = {
'admin.groups.grants': { 'admin.groups.grants': {
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', 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: { common: {
loading: 'Laden...', loading: 'Laden...',
accessDenied: 'Zugriff verweigert', accessDenied: 'Zugriff verweigert',
@@ -67,6 +76,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
})); }));
import AdminModuleGrantsPage from './page'; import AdminModuleGrantsPage from './page';
import { ActivateModuleDialog } from '../components/ActivateModuleDialog';
function stubAdmin() { function stubAdmin() {
mockAuthStore.mockImplementation( 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(
<ActivateModuleDialog
open
moduleId="m1"
moduleName="Ausschreibungs-Radar"
onCancel={vi.fn()}
onSuccess={vi.fn()}
onError={vi.fn()}
/>,
);
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(
<ActivateModuleDialog
open
moduleId="m1"
moduleName="Ausschreibungs-Radar"
onCancel={vi.fn()}
onSuccess={vi.fn()}
onError={vi.fn()}
/>,
);
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(
<ActivateModuleDialog
open
moduleId="m1"
moduleName="Ausschreibungs-Radar"
onCancel={vi.fn()}
onSuccess={onSuccess}
onError={vi.fn()}
/>,
);
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');
});
});
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
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 { ActivateModuleDialog } from './components/ActivateModuleDialog';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; 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 [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);
// 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<Module | null>(null);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
@@ -192,7 +197,16 @@ export default function AdminModulesPage() {
{isActive ? t('activated') : t('deactivated')} {isActive ? t('activated') : t('deactivated')}
</span> </span>
<button <button
onClick={() => toggleModule(mod.id, isActive)} onClick={() => {
if (isActive) {
// Deactivation is unchanged -- direct toggle, no dialog.
toggleModule(mod.id, true);
} else {
// Activation opens the confirmation dialog instead of
// activating directly (D-10).
setActivateDialogModule(mod);
}
}}
disabled={isToggling} disabled={isToggling}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
isActive ? 'bg-primary' : 'bg-input' isActive ? 'bg-primary' : 'bg-input'
@@ -212,6 +226,25 @@ export default function AdminModulesPage() {
})} })}
</div> </div>
)} )}
{activateDialogModule && (
<ActivateModuleDialog
open
moduleId={activateDialogModule.id}
moduleName={activateDialogModule.name}
onCancel={() => setActivateDialogModule(null)}
onSuccess={(moduleId) => {
setActivations((prev) => {
const next = new Map(prev);
next.set(moduleId, true);
return next;
});
bumpSidebarRefresh();
setActivateDialogModule(null);
}}
onError={(message) => setError(message)}
/>
)}
</div> </div>
); );
} }