feat(260923-dhh): Proxmox-Modul Aufgabe 5 - Einstellungsseite (anlegen, bearbeiten, loeschen, testen)

- proxmox.service.ts: updateServer (Muster LdapConfigService.updateConfig
  - nicht gesendet laesst unveraendert, leer loescht, gefuellt
  verschluesselt neu; PMG+Token auch beim Bearbeiten abgelehnt, geprueft
  gegen den EFFEKTIVEN Stand nach Zusammenfuehren), deleteServer
- proxmox.controller.ts: PUT/DELETE servers/:id, beide zusaetzlich mit
  scheduler.refreshTenant() nach dem Schreiben
- Frontend: proxmox-api.ts (updateServer/deleteServer/testServer),
  settings/page.tsx (Rollenpruefung nur Anzeige, Serverliste,
  Loeschen mit Rueckfrage), ServerForm.tsx (PMG bietet Token gar nicht
  an, Geheimnisfelder nie vorbefuellt, Zertifikatspruefung-Schalter
  Standard "pruefen", Verbindungstest mit Klartext-Fehlertext)
- umlaut-dictionary.ts: zwei neue, bereits korrekte Woerter
  (bewusst/gemessene) auf die Positivliste des Regressions-Waechters

Tore: api 1311/1311 (>=1240), web 701/701 (>=693), type-check 4/4,
lint 5/5, Biome apps/web 53 Warnungen (unveraendert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:41:21 +02:00
parent fccaf8db0f
commit 723cf6814b
10 changed files with 1112 additions and 10 deletions
+34 -2
View File
@@ -1,9 +1,19 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common'; import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard'; import { UseModule } from '../module-registry/module.guard';
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; import { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
import { ProxmoxSchedulerService } from './proxmox-scheduler.service'; import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
import { ProxmoxService } from './proxmox.service'; import { ProxmoxService } from './proxmox.service';
@@ -46,6 +56,28 @@ export class ProxmoxController {
return created; return created;
} }
@Put('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateProxmoxServerDto,
) {
const tenantId = this.requireTenantId(req);
const updated = await this.proxmoxService.updateServer(tenantId, id, dto);
await this.scheduler.refreshTenant(tenantId);
return updated;
}
@Delete('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this.requireTenantId(req);
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
await this.scheduler.refreshTenant(tenantId);
return { deleted };
}
@Post('servers/:id/poll') @Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
+91 -1
View File
@@ -24,7 +24,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
import { Agent } from 'undici'; import { Agent } from 'undici';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { ProxmoxService } from './proxmox.service'; import { ProxmoxService } from './proxmox.service';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */ /** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
const crypto = { const crypto = {
@@ -81,6 +81,28 @@ function makeFakePrisma() {
return { ...row }; return { ...row };
}, },
), ),
update: vi.fn(
async ({
where,
data,
select,
}: {
where: { id: string };
data: Record<string, unknown>;
select?: any;
}) => {
const existing = servers.get(where.id);
const updated = { ...existing, ...data, updatedAt: new Date() };
servers.set(where.id, updated);
return applySelect(updated, select);
},
),
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
const row = servers.get(where.id);
servers.delete(where.id);
statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade)
return row ? { ...row } : null;
}),
}; };
const proxmoxServerStatus = { const proxmoxServerStatus = {
@@ -434,3 +456,71 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 }); expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
}); });
}); });
describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret;
await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' });
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore);
expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name');
});
it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' });
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull();
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' });
const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret;
expect(stored).not.toBe('neues-geheimnis');
expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
});
it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', {
name: 'pmg-1',
productType: 'pmg',
baseUrl: 'https://pmg.intern',
authMethod: 'password',
username: 'admin@pmg',
password: 'geheim',
});
const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' };
await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow();
});
it('loescht einen Server samt Zwischenlagerzeile', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
const created = await service.createServer('tenant-a', TOKEN_DTO);
expect(prisma.__statuses.has((created as any).id)).toBe(true);
const deleted = await service.deleteServer('tenant-a', (created as any).id);
expect(deleted).toBe(true);
expect(prisma.__servers.has((created as any).id)).toBe(false);
expect(prisma.__statuses.has((created as any).id)).toBe(false);
});
it('deleteServer liefert false fuer einen unbekannten Server', async () => {
const prisma = makeFakePrisma();
const service = new ProxmoxService(prisma as any, crypto as any);
expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false);
});
});
+69 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import type { ProxmoxServer } from '@prisma/client'; import type { ProxmoxServer } from '@prisma/client';
import { CryptoService } from '../crypto/crypto.service'; import { CryptoService } from '../crypto/crypto.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -6,7 +6,7 @@ import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth'; import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service'; import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize'; import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types'; import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
/** /**
@@ -144,6 +144,73 @@ export class ProxmoxService {
}); });
} }
/**
* Bearbeiten (Aufgabe 5). Dieselbe Regel wie
* `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld
* laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette
* bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die
* Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den
* EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile,
* nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur
* `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen,
* weil `productType` in diesem Aufruf gar nicht gesendet wird).
*/
async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return null;
}
const effectiveProductType = dto.productType ?? existing.productType;
const effectiveAuthMethod = dto.authMethod ?? existing.authMethod;
if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') {
throw new BadRequestException(
'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.',
);
}
const data: Record<string, unknown> = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.productType !== undefined) data.productType = dto.productType;
if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl;
if (dto.authMethod !== undefined) data.authMethod = dto.authMethod;
if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null;
if (dto.tokenSecret !== undefined) {
data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null;
}
if (dto.username !== undefined) data.username = dto.username || null;
if (dto.password !== undefined) {
data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null;
}
if (dto.tlsRejectUnauthorized !== undefined) {
data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized;
}
if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
return tenantPrisma.proxmoxServer.update({
where: { id: serverId },
data,
select: SAFE_SERVER_SELECT,
});
}
/**
* Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile
* (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert
* `false`, wenn der Server unter diesem Mandanten nicht existiert.
*/
async deleteServer(tenantId: string, serverId: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
if (!existing || existing.tenantId !== tenantId) {
return false;
}
await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } });
return true;
}
/** /**
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber * Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine * `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
@@ -0,0 +1,187 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockCreateServer = vi.fn();
const mockUpdateServer = vi.fn();
const mockTestServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
createServer: (...args: unknown[]) => mockCreateServer(...args),
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
testServer: (...args: unknown[]) => mockTestServer(...args),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'settings.nameLabel': 'Name',
'settings.productTypeLabel': 'Typ',
'settings.productTypePve': 'PVE',
'settings.productTypePbs': 'PBS',
'settings.productTypePmg': 'PMG',
'settings.baseUrlLabel': 'Adresse',
'settings.authMethodLabel': 'Zugangsart',
'settings.authMethodToken': 'API-Token',
'settings.authMethodPassword': 'Benutzer/Passwort',
'settings.tokenIdLabel': 'Token-Kennung',
'settings.tokenSecretLabel': 'Token-Geheimnis',
'settings.usernameLabel': 'Benutzername',
'settings.passwordLabel': 'Passwort',
'settings.secretUnchangedPlaceholder': 'Leer lassen, um das gespeicherte Geheimnis beizubehalten',
'settings.pollIntervalLabel': 'Abfrageintervall (Minuten)',
'settings.tlsRejectLabel': 'Zertifikat prüfen',
'settings.tlsRejectHint': 'Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.',
'settings.activeLabel': 'Aktiv',
'settings.save': 'Speichern',
'settings.saving': 'Wird gespeichert...',
'settings.saveError': 'Die Einstellungen konnten nicht gespeichert werden.',
'settings.cancel': 'Abbrechen',
'settings.testConnection': 'Verbindung testen',
'settings.testTesting': 'Verbindung wird getestet...',
'settings.testSuccess': 'Verbindung erfolgreich.',
'errors.netz': 'Der Server ist nicht erreichbar.',
'errors.zugang': 'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
'errors.rechte': 'Die Rechte reichen nicht aus.',
'errors.zertifikat': 'Das Zertifikat wurde abgelehnt.',
'errors.antwortform': 'Unerwartete Antwortform.',
'errors.server': 'Serverfehler.',
'errors.unbekannt': 'Unerwarteter Fehler.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
afterEach(() => {
cleanup();
mockCreateServer.mockReset();
mockUpdateServer.mockReset();
mockTestServer.mockReset();
});
const EXISTING_SERVER = {
id: 'srv-1',
tenantId: 't1',
name: 'pmg-1',
productType: 'pmg' as const,
baseUrl: 'https://pmg.intern',
authMethod: 'password' as const,
tokenId: null,
username: 'admin@pmg',
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: null,
};
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()} />);
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
const options = [...authSelect.options].map((o) => o.value);
expect(options).toEqual(['password']);
});
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()} />);
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
expect(screen.queryByLabelText('Benutzername')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Zugangsart'), { target: { value: 'password' } });
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
expect(screen.queryByLabelText('Token-Kennung')).not.toBeInTheDocument();
});
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()} />);
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
expect(passwordInput.value).toBe('');
});
it('ein leer gelassenes Geheimnisfeld sendet kein password-Feld beim Speichern (Wert bleibt unveraendert)', async () => {
mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
const onSaved = vi.fn();
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Speichern'));
await waitFor(() => expect(mockUpdateServer).toHaveBeenCalled());
const payload = mockUpdateServer.mock.calls[0][1];
expect(payload.password).toBeUndefined();
});
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()} />);
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(
screen.getByText('Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.'),
).toBeInTheDocument();
});
it('Verbindung testen zeigt bei Erfolg eine gruene Bestaetigung', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
});
it('Verbindung testen zeigt bei Misserfolg den Klartext der Ursache', async () => {
mockTestServer.mockResolvedValue({
reachable: false,
errorKind: 'zugang',
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() =>
expect(
screen.getByText(
'Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.',
),
).toBeInTheDocument(),
);
});
it('ohne gespeicherten Server (Neuanlage) gibt es keinen Verbindung-testen-Knopf', async () => {
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByText('Verbindung testen')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,380 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
createServer,
testServer,
updateServer,
type ProxmoxAuthMethod,
type ProxmoxProductType,
type ProxmoxServer,
type ProxmoxTestResult,
} from '@/lib/proxmox-api';
interface FormState {
name: string;
productType: ProxmoxProductType;
baseUrl: string;
authMethod: ProxmoxAuthMethod;
tokenId: string;
tokenSecret: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
username: string;
password: string; // absichtlich leer beim Laden — nie aus dem Server vorbefuellt
pollIntervalMin: string;
tlsRejectUnauthorized: boolean;
isActive: boolean;
}
function serverToForm(server: ProxmoxServer | null): FormState {
if (!server) {
return {
name: '',
productType: 'pve',
baseUrl: '',
authMethod: 'token',
tokenId: '',
tokenSecret: '',
username: '',
password: '',
pollIntervalMin: '5',
tlsRejectUnauthorized: true,
isActive: true,
};
}
return {
name: server.name,
productType: server.productType,
baseUrl: server.baseUrl,
authMethod: server.authMethod,
tokenId: server.tokenId ?? '',
tokenSecret: '',
username: server.username ?? '',
password: '',
pollIntervalMin: String(server.pollIntervalMin),
tlsRejectUnauthorized: server.tlsRejectUnauthorized,
isActive: server.isActive,
};
}
interface ServerFormProps {
server: ProxmoxServer | null;
isAdmin: boolean;
onSaved: (server: ProxmoxServer) => void;
onCancel: () => void;
}
/**
* Server anlegen/bearbeiten (Aufgabe 5). Bei Typ `pmg` bietet die Auswahl
* "API-Token" gar nicht erst an (D-03) — serverseitig lehnt das DTO diese
* Kombination zusaetzlich ab (Verteidigung in der Tiefe). Ein gespeichertes
* 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) {
const t = useTranslations('proxmox');
const [form, setForm] = useState<FormState>(() => serverToForm(server));
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [testResult, setTestResult] = useState<ProxmoxTestResult | null>(null);
const update = useCallback(
<K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((f) => ({ ...f, [key]: value }));
setSaveError(null);
setTestResult(null);
},
[],
);
const handleProductTypeChange = (productType: ProxmoxProductType) => {
setForm((f) => ({
...f,
productType,
// PMG kennt keinen Token — bei Wechsel auf PMG automatisch auf Passwort umstellen.
authMethod: productType === 'pmg' ? 'password' : f.authMethod,
}));
setSaveError(null);
setTestResult(null);
};
const buildPayload = () => ({
name: form.name,
productType: form.productType,
baseUrl: form.baseUrl,
authMethod: form.authMethod,
tokenId: form.authMethod === 'token' ? form.tokenId : undefined,
tokenSecret: form.authMethod === 'token' && form.tokenSecret ? form.tokenSecret : undefined,
username: form.authMethod === 'password' ? form.username : undefined,
password: form.authMethod === 'password' && form.password ? form.password : undefined,
pollIntervalMin: Number(form.pollIntervalMin) || 5,
tlsRejectUnauthorized: form.tlsRejectUnauthorized,
isActive: form.isActive,
});
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
try {
const result = savedServer
? await updateServer(savedServer.id, buildPayload())
: await createServer(buildPayload());
setSavedServer(result);
setForm(serverToForm(result));
onSaved(result);
} catch (err) {
setSaveError(err instanceof Error ? err.message : t('settings.saveError'));
} finally {
setIsSaving(false);
}
};
const handleTest = async () => {
if (!savedServer) return;
setIsTesting(true);
setTestResult(null);
try {
const result = await testServer(savedServer.id);
setTestResult(result);
} catch (err) {
setTestResult({
reachable: false,
errorKind: 'unbekannt',
errorDetail: err instanceof Error ? err.message : t('settings.saveError'),
metrics: null,
rawSample: null,
});
} finally {
setIsTesting(false);
}
};
const inputCls =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50';
const labelCls = 'mb-1 block text-sm text-foreground';
const errorMessage = (kind: ProxmoxTestResult['errorKind']) => {
if (!kind) return null;
return t(`errors.${kind}`);
};
return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4 shadow-sm">
<div>
<label htmlFor="proxmox-name" className={labelCls}>
{t('settings.nameLabel')}
</label>
<input
id="proxmox-name"
type="text"
className={inputCls}
value={form.name}
disabled={!isAdmin}
onChange={(e) => update('name', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-product-type" className={labelCls}>
{t('settings.productTypeLabel')}
</label>
<select
id="proxmox-product-type"
className={inputCls}
value={form.productType}
disabled={!isAdmin}
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
>
<option value="pve">{t('settings.productTypePve')}</option>
<option value="pbs">{t('settings.productTypePbs')}</option>
<option value="pmg">{t('settings.productTypePmg')}</option>
</select>
</div>
<div>
<label htmlFor="proxmox-base-url" className={labelCls}>
{t('settings.baseUrlLabel')}
</label>
<input
id="proxmox-base-url"
type="text"
placeholder="https://pve.intern:8006"
className={inputCls}
value={form.baseUrl}
disabled={!isAdmin}
onChange={(e) => update('baseUrl', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-auth-method" className={labelCls}>
{t('settings.authMethodLabel')}
</label>
<select
id="proxmox-auth-method"
className={inputCls}
value={form.authMethod}
disabled={!isAdmin}
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. */}
{form.productType !== 'pmg' && <option value="token">{t('settings.authMethodToken')}</option>}
<option value="password">{t('settings.authMethodPassword')}</option>
</select>
</div>
{form.authMethod === 'token' ? (
<>
<div>
<label htmlFor="proxmox-token-id" className={labelCls}>
{t('settings.tokenIdLabel')}
</label>
<input
id="proxmox-token-id"
type="text"
placeholder="root@pam!tessera"
className={inputCls}
value={form.tokenId}
disabled={!isAdmin}
onChange={(e) => update('tokenId', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-token-secret" className={labelCls}>
{t('settings.tokenSecretLabel')}
</label>
<input
id="proxmox-token-secret"
type="password"
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.tokenSecret}
disabled={!isAdmin}
onChange={(e) => update('tokenSecret', e.target.value)}
/>
</div>
</>
) : (
<>
<div>
<label htmlFor="proxmox-username" className={labelCls}>
{t('settings.usernameLabel')}
</label>
<input
id="proxmox-username"
type="text"
placeholder="admin@pam"
className={inputCls}
value={form.username}
disabled={!isAdmin}
onChange={(e) => update('username', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-password" className={labelCls}>
{t('settings.passwordLabel')}
</label>
<input
id="proxmox-password"
type="password"
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls}
value={form.password}
disabled={!isAdmin}
onChange={(e) => update('password', e.target.value)}
/>
</div>
</>
)}
<div>
<label htmlFor="proxmox-poll-interval" className={labelCls}>
{t('settings.pollIntervalLabel')}
</label>
<input
id="proxmox-poll-interval"
type="number"
min={1}
max={1440}
className={inputCls}
value={form.pollIntervalMin}
disabled={!isAdmin}
onChange={(e) => update('pollIntervalMin', e.target.value)}
/>
</div>
<div>
<label htmlFor="proxmox-tls-reject" className="flex items-center gap-2 text-sm text-foreground">
<input
id="proxmox-tls-reject"
type="checkbox"
checked={form.tlsRejectUnauthorized}
disabled={!isAdmin}
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
/>
{t('settings.tlsRejectLabel')}
</label>
<p className="mt-1 text-xs text-muted-foreground">{t('settings.tlsRejectHint')}</p>
</div>
<div>
<label htmlFor="proxmox-active" className="flex items-center gap-2 text-sm text-foreground">
<input
id="proxmox-active"
type="checkbox"
checked={form.isActive}
disabled={!isAdmin}
onChange={(e) => update('isActive', e.target.checked)}
/>
{t('settings.activeLabel')}
</label>
</div>
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
{testResult && (
<p
className={`text-sm ${testResult.reachable ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
>
{testResult.reachable
? t('settings.testSuccess')
: `${errorMessage(testResult.errorKind)}${testResult.errorDetail ? ` (${testResult.errorDetail})` : ''}`}
</p>
)}
{isAdmin && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving || !form.name || !form.baseUrl}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? t('settings.saving') : t('settings.save')}
</button>
{savedServer && (
<button
type="button"
onClick={handleTest}
disabled={isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
)}
<button
type="button"
onClick={onCancel}
className="rounded px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
>
{t('settings.cancel')}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,197 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { ServerForm } from './components/ServerForm';
interface DeleteDialogProps {
name: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogProps) {
const t = useTranslations('proxmox');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded border border-border bg-card px-6 py-5 shadow-lg">
<h3 className="mb-2 text-base font-semibold text-foreground">
{t('settings.deleteConfirmTitle')}
</h3>
<p className="mb-5 text-sm text-muted-foreground">
{t('settings.deleteConfirmBody', { name })}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.deleteCancelButton')}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('settings.deleteConfirmButton')}
</button>
</div>
</div>
</div>
);
}
/**
* 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`).
*/
export default function ProxmoxSettingsPage() {
const t = useTranslations('proxmox');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [editingId, setEditingId] = useState<string | 'new' | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxmoxServer | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const reload = useCallback(() => {
listServers()
.then(setServers)
.catch(() => setLoadError(t('loadError')));
}, [t]);
useEffect(() => {
reload();
}, [reload]);
const handleSaved = () => {
setEditingId(null);
reload();
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteServer(deleteTarget.id);
setDeleteTarget(null);
reload();
} finally {
setIsDeleting(false);
}
};
if (user === null) {
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="h-40 animate-pulse rounded bg-muted" />
</div>
);
}
if (!isAdmin) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
<p className="text-sm text-muted-foreground">{t('settings.accessDeniedText')}</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
{editingId === null && (
<button
type="button"
onClick={() => setEditingId('new')}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('settings.addServer')}
</button>
)}
</div>
{loadError && <p className="text-sm text-destructive">{loadError}</p>}
{editingId === 'new' && (
<ServerForm
server={null}
isAdmin={isAdmin}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
)}
{servers !== null && servers.length === 0 && editingId === null && (
<p className="text-sm text-muted-foreground">{t('settings.noServers')}</p>
)}
<ul className="space-y-3">
{servers?.map((server) =>
editingId === server.id ? (
<li key={server.id}>
<ServerForm
server={server}
isAdmin={isAdmin}
onSaved={handleSaved}
onCancel={() => setEditingId(null)}
/>
</li>
) : (
<li
key={server.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 shadow-sm"
>
<div>
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.productType.toUpperCase()} — {server.baseUrl}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setEditingId(server.id)}
className="rounded border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
{t('settings.edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(server)}
className="rounded border border-destructive px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10"
>
{t('settings.delete')}
</button>
</div>
</li>
),
)}
</ul>
{deleteTarget && (
<DeleteDialog
name={deleteTarget.name}
isDeleting={isDeleting}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
);
}
+57 -3
View File
@@ -68,6 +68,26 @@ export interface CreateProxmoxServerPayload {
export type UpdateProxmoxServerPayload = Partial<CreateProxmoxServerPayload>; export type UpdateProxmoxServerPayload = Partial<CreateProxmoxServerPayload>;
export interface ProxmoxTestResult {
reachable: boolean;
errorKind: ProxmoxErrorKind | null;
errorDetail: string | null;
metrics: unknown;
rawSample: unknown;
}
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const body = await res.json();
if (typeof body?.message === 'string') return body.message;
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
} catch {
/* Antwort war kein JSON — Standardtext bleibt */
}
return fallback;
}
/** GET /modules/proxmox/servers — Serverliste samt Zwischenlager. */ /** GET /modules/proxmox/servers — Serverliste samt Zwischenlager. */
export async function listServers(): Promise<ProxmoxServer[]> { export async function listServers(): Promise<ProxmoxServer[]> {
const res = await fetch(`${API_URL}/modules/proxmox/servers`, { const res = await fetch(`${API_URL}/modules/proxmox/servers`, {
@@ -87,16 +107,50 @@ export async function createServer(
credentials: 'include', credentials: 'include',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) throw new Error('Failed to create proxmox server'); if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create proxmox server'));
return res.json(); return res.json();
} }
/** PUT /modules/proxmox/servers/:id — Server bearbeiten (ADMIN/SUPER_ADMIN). */
export async function updateServer(
id: string,
payload: UpdateProxmoxServerPayload,
): Promise<ProxmoxServer> {
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update proxmox server'));
return res.json();
}
/** DELETE /modules/proxmox/servers/:id — Server samt Zwischenlagerzeile loeschen (ADMIN/SUPER_ADMIN). */
export async function deleteServer(id: string): Promise<void> {
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete proxmox server'));
}
/** POST /modules/proxmox/servers/:id/poll — sofortige Abfrage (ADMIN/SUPER_ADMIN). */ /** POST /modules/proxmox/servers/:id/poll — sofortige Abfrage (ADMIN/SUPER_ADMIN). */
export async function pollServer(id: string): Promise<unknown> { export async function pollServer(id: string): Promise<ProxmoxTestResult> {
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/poll`, { const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/poll`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) throw new Error('Failed to poll proxmox server'); if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to poll proxmox server'));
return res.json();
}
/** POST /modules/proxmox/servers/:id/test — Verbindungstest, schreibt NICHT ins Zwischenlager. */
export async function testServer(id: string): Promise<ProxmoxTestResult> {
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/test`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
return res.json(); return res.json();
} }
+47 -1
View File
@@ -708,7 +708,53 @@
"description": "Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick — Tessera schaut nur zu, es verändert nichts.", "description": "Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick — Tessera schaut nur zu, es verändert nichts.",
"loading": "Lade Serverliste...", "loading": "Lade Serverliste...",
"loadError": "Die Serverliste konnte nicht geladen werden.", "loadError": "Die Serverliste konnte nicht geladen werden.",
"emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an." "emptyState": "Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.",
"errors": {
"netz": "Der Server ist nicht erreichbar. Bitte prüfen Sie die Adresse und die Netzwerkverbindung.",
"zugang": "Der Zugang wurde abgelehnt. Bitte prüfen Sie Benutzername und Passwort beziehungsweise die Token-Angaben.",
"rechte": "Die Rechte des hinterlegten Zugangs reichen nicht aus. Bitte prüfen Sie die zugewiesene Rolle am Proxmox-Server.",
"zertifikat": "Das Zertifikat des Servers wurde abgelehnt. Prüfen Sie die Adresse oder schalten Sie die Zertifikatsprüfung für diesen einen Server bewusst ab.",
"antwortform": "Die Antwort des Servers hatte nicht die erwartete Form. Bitte prüfen Sie die Adresse.",
"server": "Der Proxmox-Server meldet einen eigenen Fehler. Bitte versuchen Sie es später erneut.",
"unbekannt": "Ein unerwarteter Fehler ist aufgetreten."
},
"settings": {
"title": "Proxmox — Einstellungen",
"accessDeniedText": "Diese Seite steht nur Administratoren zur Verfügung.",
"addServer": "Server hinzufügen",
"noServers": "Noch kein Server eingetragen.",
"nameLabel": "Name",
"productTypeLabel": "Typ",
"productTypePve": "PVE",
"productTypePbs": "PBS",
"productTypePmg": "PMG",
"baseUrlLabel": "Adresse",
"authMethodLabel": "Zugangsart",
"authMethodToken": "API-Token",
"authMethodPassword": "Benutzer/Passwort",
"tokenIdLabel": "Token-Kennung",
"tokenSecretLabel": "Token-Geheimnis",
"usernameLabel": "Benutzername",
"passwordLabel": "Passwort",
"secretUnchangedPlaceholder": "Leer lassen, um das gespeicherte Geheimnis beizubehalten",
"pollIntervalLabel": "Abfrageintervall (Minuten)",
"tlsRejectLabel": "Zertifikat prüfen",
"tlsRejectHint": "Die Ausnahme gilt nur für diesen einen Server, niemals für alle Server gemeinsam.",
"activeLabel": "Aktiv",
"save": "Speichern",
"saving": "Wird gespeichert...",
"saveError": "Die Einstellungen konnten nicht gespeichert werden.",
"cancel": "Abbrechen",
"testConnection": "Verbindung testen",
"testTesting": "Verbindung wird getestet...",
"testSuccess": "Verbindung erfolgreich.",
"edit": "Bearbeiten",
"delete": "Löschen",
"deleteConfirmTitle": "Server löschen",
"deleteConfirmBody": "Möchten Sie den Server \"{name}\" wirklich löschen? Der zuletzt gemessene Stand wird mit entfernt.",
"deleteConfirmButton": "Löschen",
"deleteCancelButton": "Abbrechen"
}
}, },
"dkvFleet": { "dkvFleet": {
"pageTitle": "DKV-Rechnung", "pageTitle": "DKV-Rechnung",
+47 -1
View File
@@ -708,7 +708,53 @@
"description": "State of your Proxmox servers (PVE/PBS/PMG) at a glance — Tessera only observes, it never changes anything.", "description": "State of your Proxmox servers (PVE/PBS/PMG) at a glance — Tessera only observes, it never changes anything.",
"loading": "Loading server list...", "loading": "Loading server list...",
"loadError": "Could not load the server list.", "loadError": "Could not load the server list.",
"emptyState": "No server configured yet. Add one in the module settings." "emptyState": "No server configured yet. Add one in the module settings.",
"errors": {
"netz": "The server is unreachable. Please check the address and the network connection.",
"zugang": "Access was denied. Please check the username and password, or the token details.",
"rechte": "The stored account does not have enough rights. Please check the assigned role on the Proxmox server.",
"zertifikat": "The server's certificate was rejected. Check the address, or deliberately disable certificate checking for this one server.",
"antwortform": "The server's response did not have the expected shape. Please check the address.",
"server": "The Proxmox server reports its own error. Please try again later.",
"unbekannt": "An unexpected error occurred."
},
"settings": {
"title": "Proxmox — Settings",
"accessDeniedText": "This page is only available to administrators.",
"addServer": "Add server",
"noServers": "No server configured yet.",
"nameLabel": "Name",
"productTypeLabel": "Type",
"productTypePve": "PVE",
"productTypePbs": "PBS",
"productTypePmg": "PMG",
"baseUrlLabel": "Address",
"authMethodLabel": "Access method",
"authMethodToken": "API token",
"authMethodPassword": "Username/password",
"tokenIdLabel": "Token ID",
"tokenSecretLabel": "Token secret",
"usernameLabel": "Username",
"passwordLabel": "Password",
"secretUnchangedPlaceholder": "Leave blank to keep the stored secret",
"pollIntervalLabel": "Poll interval (minutes)",
"tlsRejectLabel": "Verify certificate",
"tlsRejectHint": "The exception applies only to this one server, never to all servers at once.",
"activeLabel": "Active",
"save": "Save",
"saving": "Saving...",
"saveError": "Could not save the settings.",
"cancel": "Cancel",
"testConnection": "Test connection",
"testTesting": "Testing connection...",
"testSuccess": "Connection successful.",
"edit": "Edit",
"delete": "Delete",
"deleteConfirmTitle": "Delete server",
"deleteConfirmBody": "Do you really want to delete the server \"{name}\"? The last measured status will be removed as well.",
"deleteConfirmButton": "Delete",
"deleteCancelButton": "Cancel"
}
}, },
"dkvFleet": { "dkvFleet": {
"pageTitle": "DKV Invoice", "pageTitle": "DKV Invoice",
@@ -184,4 +184,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'SSL', 'SSL',
// 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?" // 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?"
'passiert', 'passiert',
// quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“
'bewusst',
'gemessene',
]; ];