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
+12 -17
View File
@@ -15,8 +15,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { ModuleManage } from '../module-registry/module.guard';
import type {
AuthenticatedRequest,
UploadedFileLike,
@@ -29,11 +28,17 @@ import { DkvHistoryQueryDto } from './dto/dkv-history.dto';
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
/**
* DkvController — all /dkv/* routes, ADMIN-only (V4).
* DkvController — all /dkv/* routes, manager level (V4, 261002-icv).
*
* Every handler carries @Roles(Role.ADMIN, Role.SUPER_ADMIN).
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
* @Roles decorator. No route is publicly accessible.
* The whole module is Verwalten-level: `@ModuleManage('dkv-fleet')` on the
* class replaces the former per-handler @Roles(ADMIN, SUPER_ADMIN). Access is
* therefore limited to administrators and to users with the grant level
* "Verwalten" (MANAGE) on the dkv-fleet module. Users with only "Benutzen"
* (USE) keep getting 403 exactly as before — nothing was widened. The class
* guard additionally requires the dkv-fleet module to be active for the
* tenant (the web page already required that).
* Global JwtAuthGuard enforces JWT authentication; ModuleGuard enforces the
* grant level. No route is publicly accessible.
*
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
* All operations are scoped to the authenticated tenant's data.
@@ -52,6 +57,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
* POST /dkv/vehicles/import — bulk-import from CSV upload
*/
@Controller('dkv')
@ModuleManage('dkv-fleet')
export class DkvController {
constructor(
private readonly dkvService: DkvService,
@@ -62,7 +68,6 @@ export class DkvController {
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId);
@@ -79,7 +84,6 @@ export class DkvController {
* or stops the cron job if isActive is false.
*/
@Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto);
@@ -98,7 +102,6 @@ export class DkvController {
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId);
@@ -109,7 +112,6 @@ export class DkvController {
* Used by the InboxConfigForm "Verbindung testen" button before saving.
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto);
@@ -122,7 +124,6 @@ export class DkvController {
* T-07-06: pagination parameters validated by DkvHistoryQueryDto.
*/
@Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req);
const page = query.page ?? 1;
@@ -140,7 +141,6 @@ export class DkvController {
* containing path separators or non-whitelisted characters is rejected.
*/
@Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport(
@Req() req: AuthenticatedRequest,
@Param('filename') filename: string,
@@ -168,7 +168,6 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId);
@@ -176,7 +175,6 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto);
@@ -184,7 +182,6 @@ export class DkvController {
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
@Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -196,7 +193,6 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id);
@@ -213,7 +209,6 @@ export class DkvController {
* The controller reads `file.buffer.toString('utf-8')` and passes to DkvService.
*/
@Post('vehicles/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
}))
@@ -1,9 +1,8 @@
import 'reflect-metadata';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
import { HandelswareDatevController, parseNewAccountsField } from './handelsware-datev.controller';
@@ -36,11 +35,10 @@ describe('HandelswareDatevController — Metadaten', () => {
);
});
it('PUT settings verlangt ADMIN/SUPER_ADMIN, alles andere keine Routen-Rolle', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toEqual([
Role.ADMIN,
Role.SUPER_ADMIN,
]);
it('PUT settings verlangt die Freigabestufe Verwalten, alles andere keine Routen-Rolle und kein Verwalten (261002-icv)', () => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('handelsware-datev');
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
for (const name of [
'getSettings',
'preview',
@@ -53,6 +51,7 @@ describe('HandelswareDatevController — Metadaten', () => {
'deleteAccount',
]) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
}
});
@@ -13,11 +13,9 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { decodeUploadFilename } from '../accounting/decode-upload-filename';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
import { HandelswareDatevService } from './handelsware-datev.service';
@@ -61,7 +59,8 @@ export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto:
/**
* `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern
* nur Administratoren (T-FM5-02); die Kontenliste pflegen alle Benutzer mit
* Administratoren und Benutzer mit der Freigabestufe Verwalten
* (`@ModuleManage`, 261002-icv; T-FM5-02); die Kontenliste pflegen alle Benutzer mit
* Modulzugriff.
*
* REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`,
@@ -88,7 +87,7 @@ export class HandelswareDatevController {
}
@Put('settings')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('handelsware-datev')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) {
return this.service.saveSettings(this.requireTenantId(req), dto);
}
@@ -0,0 +1,100 @@
import 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { DkvController } from '../dkv/dkv.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from './module.guard';
/**
* Metadaten-Beweis für die Freigabestufe Verwalten (261002-icv, L-04/L-09):
* welche Handler auf `@ModuleManage` umgestellt wurden und welche bewusst
* Administratoren vorbehalten bleiben (T-icv-01/06/07/08). Reine Metadaten —
* kein Nest-Start, keine Datenbank.
*/
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
function methodsOf(controller: { prototype: object }): string[] {
return Object.getOwnPropertyNames(controller.prototype).filter(
(name) => name !== 'constructor' && typeof (controller.prototype as any)[name] === 'function',
);
}
function handler(controller: { prototype: object }, name: string) {
return (controller.prototype as any)[name];
}
function expectManage(controller: { prototype: object }, name: string, slug: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn), `${name} MODULE_SLUG_KEY`).toBe(slug);
expect(Reflect.getMetadata(GUARDS_METADATA, fn), `${name} guards`).toContain(ModuleGuard);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toBeUndefined();
}
function expectAdminOnly(controller: { prototype: object }, name: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBeUndefined();
}
describe('Umgestellte Handler (Verwalten)', () => {
it('DkvController: ganze Klasse Verwalten, kein Handler trägt @Roles', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DkvController)).toBe('dkv-fleet');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard);
const names = methodsOf(DkvController).filter((n) => Reflect.hasMetadata('path', handler(DkvController, n)));
expect(names.length).toBe(11);
for (const name of names) {
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
}
});
it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])(
'ProxmoxController.%s verlangt Verwalten für proxmox',
(name) => {
expectManage(ProxmoxController, name, 'proxmox');
},
);
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
const fn = handler(ProxmoxController, 'list');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
});
});
describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])(
'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(TendersController, name);
},
);
it.each(['matrix', 'userAccess', 'create', 'remove'])(
'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleGrantsController, name);
},
);
it.each(['activate', 'deactivate'])(
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleRegistryController, name);
},
);
});
@@ -30,7 +30,9 @@ import type { ProxmoxErrorKind } from './proxmox.types';
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf
* ein Administrator oder ein Benutzer, dem der Administrator ausdruecklich
* die Freigabestufe Verwalten fuer das Proxmox-Modul gegeben hat
* (`@ModuleManage('proxmox')`, 261002-icv), Adressen eintragen darf
* — siehe Bedrohungsmodell T-DHH-02 im Plan.
*/
+12 -12
View File
@@ -9,10 +9,8 @@ import {
Put,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import {
CreateProxmoxServerDto,
TestProxmoxServerDto,
@@ -26,9 +24,11 @@ import { ProxmoxService } from './proxmox.service';
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`,
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei
* Modulzugriff offen; Schreiben (`POST servers`, `PUT`/`DELETE servers/:id`,
* `POST servers/test`, `POST servers/:id/poll`, `POST servers/:id/test`)
* zusaetzlich `@ModuleManage('proxmox')` — Administratoren und Benutzer mit
* der Freigabestufe Verwalten (261002-icv, vorher `@Roles(ADMIN,
* SUPER_ADMIN)`; T-DHH-05). `servers/test` (statisch, zwei
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
@@ -55,7 +55,7 @@ export class ProxmoxController {
}
@Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
const tenantId = this.requireTenantId(req);
const created = await this.proxmoxService.createServer(tenantId, dto);
@@ -65,7 +65,7 @@ export class ProxmoxController {
}
@Put('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -78,7 +78,7 @@ export class ProxmoxController {
}
@Delete('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this.requireTenantId(req);
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
@@ -87,7 +87,7 @@ export class ProxmoxController {
}
@Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
}
@@ -101,7 +101,7 @@ export class ProxmoxController {
* OHNE den Zwischenlagerstand zu ueberschreiben.
*/
@Post('servers/:id/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async test(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -115,7 +115,7 @@ export class ProxmoxController {
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
*/
@Post('servers/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
this.requireTenantId(req);
return this.proxmoxService.testDraftConnection(dto);
@@ -41,12 +41,12 @@ type Message = { kind: 'error' | 'ok'; text: string; code?: string | null };
*/
export function ImportTab({
settings,
isAdmin,
canManage,
onOpenSettings,
onAccountsChanged,
}: {
settings: HandelswareSettings | null;
isAdmin: boolean;
canManage: boolean;
onOpenSettings: () => void;
onAccountsChanged: () => void;
}) {
@@ -154,8 +154,8 @@ export function ImportTab({
<div className="space-y-5">
{blockedBySettings && (
<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}
@@ -116,14 +116,24 @@ async function upload(file = xlsx()) {
const downloadButton = () =>
screen.getByRole('button', { name: 'Buchungsdatei herunterladen' }) as HTMLButtonElement;
/** 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');
api.getHandelswareSettings.mockResolvedValue(CONFIGURED);
api.listAccounts.mockResolvedValue(ACCOUNTS);
stubActiveModules([{ slug: 'handelsware-datev', canManage: false }]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
for (const m of [...Object.values(api), mockDownload, mockAuthStore]) m.mockReset();
});
@@ -219,7 +229,7 @@ describe('HandelswareDatevPage — Import', () => {
it('normaler Benutzer sieht "Ein Administrator muss zuerst …" und keinen Einstellungen-Reiter', async () => {
api.getHandelswareSettings.mockResolvedValue(EMPTY);
render(<HandelswareDatevPage />);
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
expect(await screen.findByText(/muss zuerst Standard-Erlöskonto/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
@@ -348,6 +358,21 @@ describe('HandelswareDatevPage — Konten', () => {
});
});
describe('HandelswareDatevPage — Freigabestufe Verwalten (261002-icv)', () => {
it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => {
stubActiveModules([{ slug: 'handelsware-datev', canManage: true }]);
render(<HandelswareDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
});
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
render(<HandelswareDatevPage />);
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
await screen.findByRole('button', { name: 'Konten' });
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
});
describe('HandelswareDatevPage — Einstellungen', () => {
it('Reiter nur für Administratoren, Eingaben validiert, Speichern ruft die API', async () => {
mockUser('SUPER_ADMIN');
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { getHandelswareSettings, type HandelswareSettings } from '@/lib/handelsware-datev-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountsTab } from './components/AccountsTab';
import { ImportTab } from './components/ImportTab';
import { SettingsTab } from './components/SettingsTab';
@@ -15,13 +15,13 @@ type TabId = 'import' | 'accounts' | 'settings';
/**
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
* DATEV-Buchungsdatei herunterladen. Reiter Import / Konten / Einstellungen
* (letzterer nur fuer Administratoren). Nach einem Export zaehlt `accountsVersion`
* (letzterer fuer Administratoren und Benutzer mit der Freigabestufe Verwalten). Nach einem Export zaehlt `accountsVersion`
* hoch, damit der Reiter "Konten" die neu gespeicherten Konten nachlaedt.
*/
export default function HandelswareDatevPage() {
const t = useTranslations('handelswareDatev');
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('handelsware-datev') === true;
const [tab, setTab] = useState<TabId>('import');
const [settings, setSettings] = useState<HandelswareSettings | null>(null);
@@ -45,8 +45,8 @@ export default function HandelswareDatevPage() {
{ id: 'import', label: t('tabs.import') },
{ id: 'accounts', label: t('tabs.accounts') },
];
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !isAdmin ? 'import' : tab;
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !canManage ? 'import' : tab;
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
@@ -60,7 +60,7 @@ export default function HandelswareDatevPage() {
{activeTab === 'import' && (
<ImportTab
settings={settings}
isAdmin={isAdmin}
canManage={canManage}
onOpenSettings={() => setTab('settings')}
onAccountsChanged={() => setAccountsVersion((v) => v + 1)}
/>
@@ -125,7 +125,7 @@ describe('KantineDatevPage — nicht eingerichtet', () => {
it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => {
mockGetSettings.mockResolvedValue(EMPTY);
render(<KantineDatevPage />);
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
expect(await screen.findByText(/muss zuerst Beraternummer/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
});
@@ -74,9 +74,9 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
async function card(server: ProxmoxServer, isAdmin?: boolean) {
async function card(server: ProxmoxServer, canManage?: boolean) {
const { ServerCard } = await import('./ServerCard');
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
renderDe(<ServerCard server={server} canManage={canManage} now={NOW} />);
return screen.getByTestId('server-card');
}
@@ -392,7 +392,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
});
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
it('canManage={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false);
expect(
@@ -403,7 +403,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
});
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
it('canManage weggelassen: verhaelt sich wie canManage={false} (sichere Vorgabe)', async () => {
const el = await card(makeServer({ status: UNPOLLED }));
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
@@ -351,7 +351,7 @@ function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
interface ServerCardProps {
server: ProxmoxServer;
isAdmin?: boolean;
canManage?: boolean;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number;
}
@@ -363,7 +363,7 @@ interface ServerCardProps {
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
* Zwischenlager noch welche hat.
*/
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
export function ServerCard({ server, canManage = false, now = Date.now() }: ServerCardProps) {
const t = useTranslations('proxmox');
const locale = useLocale();
const health = serverHealth(server, now);
@@ -377,7 +377,7 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
body = (
<div className="space-y-1 text-sm" data-testid="orphan-notice">
<p className="text-foreground">{t('card.orphanText')}</p>
{isAdmin && (
{canManage && (
<p className="text-muted-foreground">
{t('card.orphanAdminHint')}{' '}
<Link
@@ -391,12 +391,12 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
</div>
);
} else if (health === 'idle') {
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
// (260923-le6).
// Wer nicht verwalten darf, sieht den Knopf nicht (der Poll-Endpunkt
// verlangt Verwalten fuer das Proxmox-Modul, 261002-icv) und bekommt
// deshalb den Text ohne Knopfverweis (260923-le6).
body = (
<p className="text-sm text-muted-foreground">
{isAdmin
{canManage
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
: t('card.notPolledYetAutomatic')}
</p>
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar';
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
@@ -60,13 +60,14 @@ function SkeletonCard() {
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
* verlangt (260923-le6).
* nur fuer Administratoren und Benutzer mit der Freigabestufe Verwalten, weil
* `POST servers/:id/poll` `@ModuleManage('proxmox')` verlangt (260923-le6,
* 261002-icv).
*/
export default function ProxmoxPage() {
const t = useTranslations('proxmox');
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('proxmox') === true;
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -112,7 +113,7 @@ export default function ProxmoxPage() {
title={t('title')}
description={t('description')}
actions={
isAdmin ? (
canManage ? (
<>
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
{t('card.settingsNav')}
@@ -171,7 +172,7 @@ export default function ProxmoxPage() {
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
</svg>
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
{isAdmin && (
{canManage && (
<Link
href="/modules/proxmox/settings"
className="text-sm font-medium text-foreground hover:underline"
@@ -191,7 +192,7 @@ export default function ProxmoxPage() {
<ul className="gap-4 lg:columns-2">
{sorted.map((server) => (
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
<ServerCard server={server} isAdmin={isAdmin} now={now} />
<ServerCard server={server} canManage={canManage} now={now} />
</li>
))}
</ul>
@@ -1,7 +1,7 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
import de from '@/messages/de.json';
@@ -79,8 +79,22 @@ function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServ
} as ProxmoxServer;
}
// Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv):
// Standard ist "keine Verwalten-Freigabe".
const mockFetch = vi.fn();
function stubActiveModules(entries: unknown[]) {
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
}
beforeEach(() => {
stubActiveModules([]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
mockListServers.mockReset();
mockPollServer.mockReset();
mockAuthStore.mockReset();
@@ -104,6 +118,38 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
).toBeInTheDocument();
});
it('Rolle USER mit canManage (Verwalten): Knopf, Einstellungen-Verweis und Admin-Text sichtbar (261002-icv)', async () => {
mockUser({ role: 'USER' });
stubActiveModules([{ slug: 'proxmox', canManage: true }]);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(await screen.findByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
});
it('Rolle USER mit canManage false: bleibt schreibgeschützt', async () => {
mockUser({ role: 'USER' });
stubActiveModules([{ slug: 'proxmox', canManage: false }]);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
});
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
mockUser(null);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
@@ -88,7 +88,7 @@ const EXISTING_SERVER = {
describe('ServerForm', () => {
it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
const options = [...authSelect.options].map((o) => o.value);
@@ -98,7 +98,7 @@ describe('ServerForm', () => {
it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => {
const { ServerForm } = await import('./ServerForm');
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
@@ -113,7 +113,7 @@ describe('ServerForm', () => {
it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
expect(passwordInput.value).toBe('');
@@ -123,7 +123,7 @@ describe('ServerForm', () => {
mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
const onSaved = vi.fn();
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={onSaved} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Speichern'));
@@ -134,7 +134,7 @@ describe('ServerForm', () => {
it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
@@ -152,7 +152,7 @@ describe('ServerForm', () => {
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
@@ -168,7 +168,7 @@ describe('ServerForm', () => {
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
@@ -190,7 +190,7 @@ describe('ServerForm', () => {
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText('Adresse'), {
target: { value: 'https://pve.neu:8006' },
@@ -211,7 +211,7 @@ describe('ServerForm', () => {
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
@@ -233,7 +233,7 @@ describe('ServerForm', () => {
});
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
@@ -60,7 +60,7 @@ function serverToForm(server: ProxmoxServer | null): FormState {
interface ServerFormProps {
server: ProxmoxServer | null;
isAdmin: boolean;
canManage: boolean;
onSaved: (server: ProxmoxServer) => void;
onCancel: () => void;
}
@@ -72,7 +72,7 @@ interface ServerFormProps {
* Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer
* gelassenes Feld laesst den gespeicherten Wert unveraendert.
*/
export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormProps) {
export function ServerForm({ server, canManage, onSaved, onCancel }: ServerFormProps) {
const t = useTranslations('proxmox');
const [form, setForm] = useState<FormState>(() => serverToForm(server));
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
@@ -189,7 +189,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="text"
className={inputCls}
value={form.name}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('name', e.target.value)}
/>
</div>
@@ -202,7 +202,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-product-type"
className={inputCls}
value={form.productType}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
>
<option value="pve">{t('settings.productTypePve')}</option>
@@ -221,7 +221,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="https://pve.intern:8006"
className={inputCls}
value={form.baseUrl}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('baseUrl', e.target.value)}
/>
</div>
@@ -234,7 +234,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-auth-method"
className={inputCls}
value={form.authMethod}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)}
>
{/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */}
@@ -255,7 +255,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="root@pam!tessera"
className={inputCls}
value={form.tokenId}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('tokenId', e.target.value)}
/>
</div>
@@ -269,7 +269,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.tokenSecret}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('tokenSecret', e.target.value)}
/>
</div>
@@ -286,7 +286,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="admin@pam"
className={inputCls}
value={form.username}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('username', e.target.value)}
/>
</div>
@@ -300,7 +300,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.password}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('password', e.target.value)}
/>
</div>
@@ -318,7 +318,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
max={1440}
className={inputCls}
value={form.pollIntervalMin}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('pollIntervalMin', e.target.value)}
/>
</div>
@@ -329,7 +329,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-tls-reject"
type="checkbox"
checked={form.tlsRejectUnauthorized}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
/>
{t('settings.tlsRejectLabel')}
@@ -343,7 +343,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-active"
type="checkbox"
checked={form.isActive}
disabled={!isAdmin}
disabled={!canManage}
onChange={(e) => update('isActive', e.target.checked)}
/>
{t('settings.activeLabel')}
@@ -362,7 +362,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
</p>
)}
{isAdmin && (
{canManage && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { ServerForm } from './components/ServerForm';
@@ -48,17 +48,18 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
}
/**
* Moduleinstellungen (Aufgabe 5) — ADMINISTRATION ONLY. Die Rollenpruefung
* hier ist reine Anzeige (Ladezustand solange die Rolle unbekannt ist,
* damit die Verwaltungsteile fuer einen normalen Benutzer nie kurz
* aufblitzen) — der verbindliche Riegel liegt serverseitig
* (`@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg, Vorbild
* `tender-radar/settings/page.tsx`).
* Moduleinstellungen (Aufgabe 5) — fuer Administratoren und Benutzer mit der
* Freigabestufe Verwalten (261002-icv). Die Pruefung hier ist reine Anzeige
* (Ladezustand solange die Faehigkeit unbekannt ist, damit die
* Verwaltungsteile fuer einen normalen Benutzer nie kurz aufblitzen) — der
* verbindliche Riegel liegt serverseitig (`@ModuleManage('proxmox')` auf
* jedem Schreibweg).
*/
export default function ProxmoxSettingsPage() {
const t = useTranslations('proxmox');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
// null solange unklar: Verwaltungsteile blitzen nie kurz auf (261002-icv).
const canManageOrNull = useCanManageModule('proxmox');
const canManage = canManageOrNull === true;
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [editingId, setEditingId] = useState<string | 'new' | null>(null);
@@ -93,7 +94,7 @@ export default function ProxmoxSettingsPage() {
}
};
if (user === null) {
if (canManageOrNull === null) {
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
@@ -102,7 +103,7 @@ export default function ProxmoxSettingsPage() {
);
}
if (!isAdmin) {
if (!canManage) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
@@ -127,7 +128,7 @@ export default function ProxmoxSettingsPage() {
{editingId === 'new' && (
<ServerForm
server={null}
isAdmin={isAdmin}
canManage={canManage}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
@@ -143,7 +144,7 @@ export default function ProxmoxSettingsPage() {
<li key={server.id}>
<ServerForm
server={server}
isAdmin={isAdmin}
canManage={canManage}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
@@ -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 {
+33 -15
View File
@@ -5,24 +5,27 @@ import { cookies } from 'next/headers';
const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Server-side module access check for the module page route (D-07, PERM-04).
* Server-side Freigabestufe fuer ein Modul (261002-icv): `'none'` (kein
* Zugriff), `'use'` (Benutzen) oder `'manage'` (Verwalten).
*
* Reads the session cookie, forwards it to `GET /modules/active` (the same
* ModuleAccessService.getAccessibleModuleIds resolution ModuleGuard and the
* sidebar use — D-01), and checks whether `moduleSlug` is present in the
* response. Mirrors `fetchCurrentUser()` in auth-actions.ts exactly: same
* cookie-forwarding, `credentials: 'include'`, `cache: 'no-store'`.
* Liest den Session-Cookie, leitet ihn an `GET /modules/active` weiter (die
* gleiche ModuleAccessService-Aufloesung, die ModuleGuard und Sidebar nutzen
* — D-01) und wertet `canManage` des Eintrags aus. Spiegelt
* `fetchCurrentUser()` in auth-actions.ts: gleiche Cookie-Weitergabe,
* `credentials: 'include'`, `cache: 'no-store'`.
*
* Fails closed (T-15-29): a missing session cookie, a non-ok API response,
* or a thrown network error all resolve to `false`. A broken network path
* must never open access.
* Fails closed (T-15-29): fehlender Cookie, nicht-ok-Antwort oder ein
* Netzwerkfehler ergeben `'none'`. Ein kaputter Netzwerkpfad darf nie
* Zugriff oeffnen.
*/
export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
export async function getModuleAccessLevel(
moduleSlug: string,
): Promise<'none' | 'use' | 'manage'> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) {
return false;
return 'none';
}
try {
@@ -35,12 +38,27 @@ export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
});
if (!response.ok) {
return false;
return 'none';
}
const modules: Array<{ slug: string }> = await response.json();
return modules.some((module) => module.slug === moduleSlug);
const modules: Array<{ slug: string; canManage?: boolean }> = await response.json();
const entry = modules.find((module) => module.slug === moduleSlug);
if (!entry) {
return 'none';
}
return entry.canManage === true ? 'manage' : 'use';
} catch {
return false;
return 'none';
}
}
/**
* Server-side module access check for the module page route (D-07, PERM-04).
*
* Wahr, sobald das Modul in `GET /modules/active` vorkommt — die gleiche
* Aufloesung wie ModuleGuard und Sidebar (D-01). Delegiert seit 261002-icv an
* `getModuleAccessLevel` (unveraenderte Signatur). Fails closed (T-15-29).
*/
export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
return (await getModuleAccessLevel(moduleSlug)) !== 'none';
}
+4 -3
View File
@@ -924,6 +924,7 @@
"accessDenied": {
"title": "Kein Zugriff auf dieses Modul",
"body": "Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.",
"manageRequiredBody": "Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.",
"backToDashboard": "Zur Startseite"
}
},
@@ -1011,7 +1012,7 @@
},
"settings": {
"title": "Proxmox — Einstellungen",
"accessDeniedText": "Diese Seite steht nur Administratoren zur Verfügung.",
"accessDeniedText": "Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.",
"addServer": "Server hinzufügen",
"noServers": "Noch kein Server eingetragen.",
"nameLabel": "Name",
@@ -1636,7 +1637,7 @@
"notConfigured": {
"admin": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt. Ohne diese Angaben kann keine DATEV-Datei erstellt werden.",
"adminAction": "Zu den Einstellungen",
"user": "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen."
"user": "Ein Administrator oder jemand, der dieses Modul verwalten darf, muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen."
},
"summary": {
"title": "Ergebnis der Prüfung",
@@ -1710,7 +1711,7 @@
"notConfigured": {
"admin": "Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt. Ohne diese Angaben können keine Konten zugeordnet werden.",
"adminAction": "Zu den Einstellungen",
"user": "Ein Administrator muss zuerst Standard-Erlöskonto und Startwert Gegenkonto hinterlegen."
"user": "Ein Administrator oder jemand, der dieses Modul verwalten darf, muss zuerst Standard-Erlöskonto und Startwert Gegenkonto hinterlegen."
},
"import": {
"dropPlaceholder": "Excel-Datei hierher ziehen oder klicken",
+4 -3
View File
@@ -924,6 +924,7 @@
"accessDenied": {
"title": "No Access to This Module",
"body": "You do not have access to this module. Please contact your administrator.",
"manageRequiredBody": "This module is only available to users who may manage it. Please contact your administrator.",
"backToDashboard": "Back to Dashboard"
}
},
@@ -1011,7 +1012,7 @@
},
"settings": {
"title": "Proxmox — Settings",
"accessDeniedText": "This page is only available to administrators.",
"accessDeniedText": "This page is available to administrators and to users who may manage this module.",
"addServer": "Add server",
"noServers": "No server configured yet.",
"nameLabel": "Name",
@@ -1636,7 +1637,7 @@
"notConfigured": {
"admin": "Consultant number, client number and wage type have not been set yet. Without them no DATEV file can be created.",
"adminAction": "Go to settings",
"user": "An administrator has to set the consultant number, client number and wage type first."
"user": "An administrator or someone who may manage this module has to set the consultant number, client number and wage type first."
},
"summary": {
"title": "Check result",
@@ -1710,7 +1711,7 @@
"notConfigured": {
"admin": "The default revenue account and the starting counter account have not been set yet. Without them no accounts can be assigned.",
"adminAction": "Go to settings",
"user": "An administrator has to set the default revenue account and the starting counter account first."
"user": "An administrator or someone who may manage this module has to set the default revenue account and the starting counter account first."
},
"import": {
"dropPlaceholder": "Drag an Excel file here or click",