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:
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user