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:
@@ -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': {
|
||||
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(
|
||||
<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 { 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<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';
|
||||
|
||||
@@ -192,7 +197,16 @@ export default function AdminModulesPage() {
|
||||
{isActive ? t('activated') : t('deactivated')}
|
||||
</span>
|
||||
<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}
|
||||
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'
|
||||
@@ -212,6 +226,25 @@ export default function AdminModulesPage() {
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user