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