diff --git a/apps/api/src/proxmox/proxmox.controller.ts b/apps/api/src/proxmox/proxmox.controller.ts index 0b9d276..055af0a 100644 --- a/apps/api/src/proxmox/proxmox.controller.ts +++ b/apps/api/src/proxmox/proxmox.controller.ts @@ -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 { Roles } from '../auth/decorators/roles.decorator'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; 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 { ProxmoxService } from './proxmox.service'; @@ -46,6 +56,28 @@ export class ProxmoxController { 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') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) { diff --git a/apps/api/src/proxmox/proxmox.service.spec.ts b/apps/api/src/proxmox/proxmox.service.spec.ts index b901b62..57c09eb 100644 --- a/apps/api/src/proxmox/proxmox.service.spec.ts +++ b/apps/api/src/proxmox/proxmox.service.spec.ts @@ -24,7 +24,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({ import { Agent } from 'undici'; import { forTenant } from '../prisma/prisma-tenant.extension'; 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. */ const crypto = { @@ -81,6 +81,28 @@ function makeFakePrisma() { return { ...row }; }, ), + update: vi.fn( + async ({ + where, + data, + select, + }: { + where: { id: string }; + data: Record; + 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 = { @@ -434,3 +456,71 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', 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); + }); +}); diff --git a/apps/api/src/proxmox/proxmox.service.ts b/apps/api/src/proxmox/proxmox.service.ts index 620bb25..046be5d 100644 --- a/apps/api/src/proxmox/proxmox.service.ts +++ b/apps/api/src/proxmox/proxmox.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import type { ProxmoxServer } from '@prisma/client'; import { CryptoService } from '../crypto/crypto.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 { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service'; 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'; /** @@ -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 = {}; + 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 { + 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 * `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine diff --git a/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx b/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx new file mode 100644 index 0000000..af5c9b9 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx @@ -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) => { + const translations: Record = { + '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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByText('Verbindung testen')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx b/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx new file mode 100644 index 0000000..cf33fff --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx @@ -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(() => serverToForm(server)); + const [savedServer, setSavedServer] = useState(server); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [saveError, setSaveError] = useState(null); + const [testResult, setTestResult] = useState(null); + + const update = useCallback( + (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 ( +
+
+ + update('name', e.target.value)} + /> +
+ +
+ + +
+ +
+ + update('baseUrl', e.target.value)} + /> +
+ +
+ + +
+ + {form.authMethod === 'token' ? ( + <> +
+ + update('tokenId', e.target.value)} + /> +
+
+ + update('tokenSecret', e.target.value)} + /> +
+ + ) : ( + <> +
+ + update('username', e.target.value)} + /> +
+
+ + update('password', e.target.value)} + /> +
+ + )} + +
+ + update('pollIntervalMin', e.target.value)} + /> +
+ +
+ +

{t('settings.tlsRejectHint')}

+
+ +
+ +
+ + {saveError &&

{saveError}

} + + {testResult && ( +

+ {testResult.reachable + ? t('settings.testSuccess') + : `${errorMessage(testResult.errorKind)}${testResult.errorDetail ? ` (${testResult.errorDetail})` : ''}`} +

+ )} + + {isAdmin && ( +
+ + + {savedServer && ( + + )} + + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx b/apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx new file mode 100644 index 0000000..eeabed6 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx @@ -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 ( +
+
+

+ {t('settings.deleteConfirmTitle')} +

+

+ {t('settings.deleteConfirmBody', { name })} +

+
+ + +
+
+
+ ); +} + +/** + * 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(null); + const [editingId, setEditingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [loadError, setLoadError] = useState(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 ( +
+
+
+
+ ); + } + + if (!isAdmin) { + return ( +
+

{t('settings.title')}

+

{t('settings.accessDeniedText')}

+
+ ); + } + + return ( +
+
+

{t('settings.title')}

+ {editingId === null && ( + + )} +
+ + {loadError &&

{loadError}

} + + {editingId === 'new' && ( + setEditingId(null)} + /> + )} + + {servers !== null && servers.length === 0 && editingId === null && ( +

{t('settings.noServers')}

+ )} + +
    + {servers?.map((server) => + editingId === server.id ? ( +
  • + setEditingId(null)} + /> +
  • + ) : ( +
  • +
    +
    {server.name}
    +
    + {server.productType.toUpperCase()} — {server.baseUrl} +
    +
    +
    + + +
    +
  • + ), + )} +
+ + {deleteTarget && ( + setDeleteTarget(null)} + /> + )} +
+ ); +} diff --git a/apps/web/src/lib/proxmox-api.ts b/apps/web/src/lib/proxmox-api.ts index 622e844..fa43527 100644 --- a/apps/web/src/lib/proxmox-api.ts +++ b/apps/web/src/lib/proxmox-api.ts @@ -68,6 +68,26 @@ export interface CreateProxmoxServerPayload { export type UpdateProxmoxServerPayload = Partial; +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 { + 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. */ export async function listServers(): Promise { const res = await fetch(`${API_URL}/modules/proxmox/servers`, { @@ -87,16 +107,50 @@ export async function createServer( credentials: 'include', 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(); } +/** PUT /modules/proxmox/servers/:id — Server bearbeiten (ADMIN/SUPER_ADMIN). */ +export async function updateServer( + id: string, + payload: UpdateProxmoxServerPayload, +): Promise { + 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 { + 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). */ -export async function pollServer(id: string): Promise { +export async function pollServer(id: string): Promise { const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/poll`, { method: 'POST', 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 { + 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(); } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 2ed9a07..5c2c91f 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -708,7 +708,53 @@ "description": "Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick — Tessera schaut nur zu, es verändert nichts.", "loading": "Lade Serverliste...", "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": { "pageTitle": "DKV-Rechnung", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index b400079..7c62da6 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -708,7 +708,53 @@ "description": "State of your Proxmox servers (PVE/PBS/PMG) at a glance — Tessera only observes, it never changes anything.", "loading": "Loading 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": { "pageTitle": "DKV Invoice", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 1105b4f..fe0614f 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -184,4 +184,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'SSL', // 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?" 'passiert', + // quick-260923-dhh: Proxmox-Modul — korrektes Deutsch mit „ss“ + 'bewusst', + 'gemessene', ];