feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten

- Proxmox-Schreibwege und Handelsware-Einstellungen auf @ModuleManage umgestellt
- DKV-Fleet: ganze Klasse Verwalten-Stufe, Benutzen allein bleibt ohne Zugriff
- Metadaten-Test belegt umgestellte und bewusst Administratoren vorbehaltene Handler
- Webseiten (Proxmox, Handelsware, Widget) folgen canManage, DKV-Zugriffsseite erklärt die Stufe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:31:08 +02:00
parent a222711ad9
commit c2ebc8daa0
23 changed files with 403 additions and 138 deletions
@@ -21,7 +21,7 @@ import { updateWidgetConfig } from '@/lib/dashboard-api';
// Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier
// nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus.
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import {
healthSummary,
type KeyFigure,
@@ -71,8 +71,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets');
const tp = useTranslations('proxmox');
const locale = useLocale();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
// Einstellungs-Link auch fuer Benutzer mit der Freigabestufe Verwalten (261002-icv).
const canManage = useCanManageModule('proxmox') === true;
// Lokaler Zustand aus `config` initialisiert (wie `viewMode` bei den
// Favoriten); Aenderungen gehen per updateWidgetConfig an den Server.
@@ -244,7 +244,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
if (servers.length === 0) {
const settingsHint =
isAdmin &&
canManage &&
(isEditMode ? (
<span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span>
) : (
@@ -25,6 +25,8 @@ vi.mock('next-intl/server', () => ({
const translations: Record<string, string> = {
'accessDenied.title': 'Kein Zugriff auf dieses Modul',
'accessDenied.body': 'Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.',
'accessDenied.manageRequiredBody':
'Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.',
'accessDenied.backToDashboard': 'Zur Startseite',
};
return translations[key] ?? key;
@@ -102,4 +104,51 @@ describe('ModuleAccessGate — server access gate (D-07, PERM-04)', () => {
expect(checkModuleAccess).toHaveBeenCalledTimes(1);
expect(checkModuleAccess).toHaveBeenCalledWith('tender-radar');
});
describe('Verwalten-pflichtige Module (dkv-fleet, 261002-icv)', () => {
const renderGate = async (level: 'none' | 'use' | 'manage' | Error) => {
const checkModuleAccess = vi.fn();
const getModuleAccessLevel =
level instanceof Error
? vi.fn().mockRejectedValue(level)
: vi.fn().mockResolvedValue(level);
vi.doMock('@/lib/module-access-actions', () => ({ checkModuleAccess, getModuleAccessLevel }));
const { ModuleAccessGate } = await import('./module-access-gate');
const element = await ModuleAccessGate({
moduleSlug: 'dkv-fleet',
children: <div data-testid="module-children">children</div>,
});
render(element);
return { checkModuleAccess, getModuleAccessLevel };
};
it('manage: zeigt die Kinder', async () => {
await renderGate('manage');
expect(screen.getByTestId('module-children')).toBeInTheDocument();
});
it('use: zeigt die erklärende Seite mit dem Verwalten-Hinweis, keine Kinder', async () => {
await renderGate('use');
expect(screen.getByText(/nur Benutzern zur Verfügung, die es verwalten dürfen/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('none: zeigt den Standardtext', async () => {
await renderGate('none');
expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('Fehler beim Abruf: Standardtext, keine Kinder (fail closed)', async () => {
await renderGate(new Error('network error'));
expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('nutzt getModuleAccessLevel und nicht checkModuleAccess', async () => {
const { checkModuleAccess, getModuleAccessLevel } = await renderGate('manage');
expect(getModuleAccessLevel).toHaveBeenCalledWith('dkv-fleet');
expect(checkModuleAccess).not.toHaveBeenCalled();
});
});
});
@@ -1,8 +1,16 @@
import { checkModuleAccess } from '@/lib/module-access-actions';
import { checkModuleAccess, getModuleAccessLevel } from '@/lib/module-access-actions';
import { getTranslations } from 'next-intl/server';
import type { ReactNode } from 'react';
import { ModuleAccessDenied } from './module-access-denied';
/**
* Module, die als Ganzes Verwalten-Stufe verlangen (261002-icv): die API
* traegt dort `@ModuleManage` auf der ganzen Klasse (DkvController). Benutzer
* mit nur "Benutzen" bekaemen von der API ohnehin 403 — die Seite zeigt ihnen
* stattdessen eine erklaerende Zugriffsseite.
*/
const MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet']);
interface ModuleAccessGateProps {
moduleSlug: string;
children: ReactNode;
@@ -29,6 +37,26 @@ interface ModuleAccessGateProps {
* missing.
*/
export async function ModuleAccessGate({ moduleSlug, children }: ModuleAccessGateProps) {
if (MANAGE_ONLY_MODULE_SLUGS.has(moduleSlug)) {
let level: 'none' | 'use' | 'manage' = 'none';
try {
level = await getModuleAccessLevel(moduleSlug);
} catch {
level = 'none';
}
if (level === 'manage') {
return children;
}
const tm = await getTranslations('modules');
return (
<ModuleAccessDenied
title={tm('accessDenied.title')}
body={level === 'use' ? tm('accessDenied.manageRequiredBody') : tm('accessDenied.body')}
backToDashboard={tm('accessDenied.backToDashboard')}
/>
);
}
let hasAccess = false;
try {