feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es vor dem Speichern und bestätigt den SHA-256-Fingerprint. Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch Pin ist im Schema, im Client und per CHECK ausgeschlossen. Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock und im gebauten Stand auf Desktop und 390 px. Closes #002 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Formular-Bausteine: echtes `<label>`, Fehlermeldung per `aria-describedby` verbunden,
|
||||
* 44 px hoch auf dem Handy.
|
||||
*/
|
||||
export const EINGABE_KLASSE =
|
||||
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent disabled:opacity-60 sm:h-10";
|
||||
|
||||
export function Feld({
|
||||
id,
|
||||
label,
|
||||
hilfe,
|
||||
fehler,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hilfe?: React.ReactNode;
|
||||
fehler?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{fehler ? (
|
||||
<p id={`${id}-fehler`} className="text-fehler text-sm">
|
||||
<span aria-hidden="true">✗ </span>
|
||||
{fehler}
|
||||
</p>
|
||||
) : hilfe ? (
|
||||
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||
{hilfe}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verknüpft ein Eingabefeld mit Hilfe- oder Fehlertext. */
|
||||
export function beschreibung(id: string, fehler?: string, hilfe?: unknown): Record<string, string | boolean> {
|
||||
if (fehler) return { "aria-describedby": `${id}-fehler`, "aria-invalid": true };
|
||||
if (hilfe) return { "aria-describedby": `${id}-hilfe` };
|
||||
return {};
|
||||
}
|
||||
|
||||
export function Schalter({
|
||||
id,
|
||||
label,
|
||||
hilfe,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hilfe?: React.ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (wert: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(ereignis) => onChange(ereignis.target.checked)}
|
||||
className="accent-akzent mt-0.5 h-5 w-5 shrink-0"
|
||||
{...(hilfe ? { "aria-describedby": `${id}-hilfe` } : {})}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{hilfe ? (
|
||||
<p id={`${id}-hilfe`} className="text-muted text-sm">
|
||||
{hilfe}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Text in JetBrains Mono mit Knopf zum Kopieren (Befehle, Fingerprints, Schlüssel). */
|
||||
export function Code({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<code
|
||||
className={clsx(
|
||||
"bg-grund border-linie block rounded-lg border px-3 py-2 font-mono text-xs break-all sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SERVER_COOKIE } from "@/lib/server-auswahl";
|
||||
|
||||
/**
|
||||
* Welcher Server gerade im Blick ist. Steht im Cookie, damit Übersicht, Templates und Assistent
|
||||
* ihn serverseitig kennen; ein Wechsel lädt nur die Daten neu.
|
||||
*/
|
||||
export function ServerUmschalter({
|
||||
server,
|
||||
aktiv,
|
||||
}: {
|
||||
server: { name: string; titel: string }[];
|
||||
aktiv: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
if (server.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-2">
|
||||
<label htmlFor="server-umschalter" className="text-muted text-xs font-medium tracking-wide uppercase">
|
||||
Server
|
||||
</label>
|
||||
<select
|
||||
id="server-umschalter"
|
||||
value={aktiv ?? ""}
|
||||
onChange={(ereignis) => {
|
||||
document.cookie = `${SERVER_COOKIE}=${encodeURIComponent(ereignis.target.value)}; path=/; max-age=31536000; samesite=lax`;
|
||||
router.refresh();
|
||||
}}
|
||||
className="bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 font-mono text-sm sm:h-10"
|
||||
>
|
||||
{server.map((eintrag) => (
|
||||
<option key={eintrag.name} value={eintrag.name}>
|
||||
{eintrag.name} · {eintrag.titel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user