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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 14:41:21 +02:00
parent fccaf8db0f
commit 723cf6814b
10 changed files with 1112 additions and 10 deletions
@@ -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>
);
}