feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen

- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE
- ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt
- @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage
- Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:28:24 +02:00
parent b94d267584
commit a222711ad9
17 changed files with 664 additions and 103 deletions
@@ -80,13 +80,23 @@ async function upload(file = csvFile()) {
fireEvent.change(input, { target: { files: [file] } });
}
/** Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv). */
const mockFetch = vi.fn();
function stubActiveModules(entries: unknown[]) {
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
}
beforeEach(() => {
mockUser('USER');
mockGetSettings.mockResolvedValue(CONFIGURED);
stubActiveModules([{ slug: 'kantine-datev', canManage: false }]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
for (const m of [
mockGetSettings,
mockSaveSettings,
@@ -120,6 +130,28 @@ describe('KantineDatevPage — nicht eingerichtet', () => {
});
});
describe('KantineDatevPage — Freigabestufe Verwalten (261002-icv)', () => {
it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => {
stubActiveModules([{ slug: 'kantine-datev', canManage: true }]);
render(<KantineDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
});
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
render(<KantineDatevPage />);
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
await screen.findByText('Kantinenabrechnung');
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('Administrator sieht den Reiter ohne jede Abfrage von /modules/active', async () => {
mockUser('ADMIN');
render(<KantineDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('KantineDatevPage — Abrechnung', () => {
it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW);
@@ -15,7 +15,7 @@ import {
previewKantineCsv,
saveKantineSettings,
} from '@/lib/kantine-datev-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
type TabId = 'billing' | 'settings';
@@ -33,8 +33,8 @@ const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR'
*/
export default function KantineDatevPage() {
const t = useTranslations('kantineDatev');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
const canManage = useCanManageModule('kantine-datev') === true;
const [tab, setTab] = useState<TabId>('billing');
const [settings, setSettings] = useState<KantineSettings | null>(null);
@@ -55,8 +55,8 @@ export default function KantineDatevPage() {
}, []);
const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }];
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab;
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !canManage ? 'billing' : tab;
return (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
@@ -67,7 +67,7 @@ export default function KantineDatevPage() {
<BillingTab
settings={settings}
settingsError={settingsError}
isAdmin={isAdmin}
canManage={canManage}
onOpenSettings={() => setTab('settings')}
/>
) : (
@@ -81,12 +81,12 @@ export default function KantineDatevPage() {
function BillingTab({
settings,
settingsError,
isAdmin,
canManage,
onOpenSettings,
}: {
settings: KantineSettings | null;
settingsError: boolean;
isAdmin: boolean;
canManage: boolean;
onOpenSettings: () => void;
}) {
const t = useTranslations('kantineDatev');
@@ -151,8 +151,8 @@ function BillingTab({
{notConfigured && (
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
<p>{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{isAdmin && (
<p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{canManage && (
<button
type="button"
onClick={onOpenSettings}