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:
@@ -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}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ApiModule {
|
||||
icon?: string;
|
||||
version: string;
|
||||
isSystem: boolean;
|
||||
/** Freigabestufe Verwalten (261002-icv) — nur Anzeige, bindend bleibt die API. */
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Darf der angemeldete Benutzer die Einstellungen dieses Moduls ändern
|
||||
* (Freigabestufe Verwalten, 261002-icv)?
|
||||
*
|
||||
* Rückgabe: `null` solange noch unklar (kein Benutzer geladen bzw. Abfrage
|
||||
* läuft), sonst `true`/`false`. Administratoren und Super-Administratoren
|
||||
* sind sofort `true` — das spiegelt den Kurzschluss im Backend, es gibt
|
||||
* dafür keine Abfrage. Alle anderen fragen einmal `GET /modules/active` ab
|
||||
* und sind nur `true`, wenn der Eintrag dieses Moduls `canManage === true`
|
||||
* trägt; ein Fehler zählt als `false`.
|
||||
*
|
||||
* Reine Anzeigehilfe: Welche Schaltflächen sichtbar sind, entscheidet nichts
|
||||
* über die Berechtigung — bindend ist allein der ModuleGuard der API.
|
||||
*/
|
||||
export function useCanManageModule(moduleSlug: string): boolean | null {
|
||||
const role = useAuthStore((s) => s.user?.role ?? null);
|
||||
const hasUser = useAuthStore((s) => s.user !== null && s.user !== undefined);
|
||||
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
|
||||
const [fetched, setFetched] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUser || isAdmin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/modules/active`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (!cancelled) setFetched(false);
|
||||
return;
|
||||
}
|
||||
const modules = (await response.json()) as Array<{ slug: string; canManage?: boolean }>;
|
||||
const entry = Array.isArray(modules) ? modules.find((m) => m.slug === moduleSlug) : null;
|
||||
if (!cancelled) setFetched(entry?.canManage === true);
|
||||
} catch {
|
||||
if (!cancelled) setFetched(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasUser, isAdmin, moduleSlug]);
|
||||
|
||||
if (!hasUser) return null;
|
||||
if (isAdmin) return true;
|
||||
return fetched;
|
||||
}
|
||||
Reference in New Issue
Block a user