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:
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user