feat: add frontend prototype

This commit is contained in:
Philipp
2026-06-10 20:14:46 +02:00
parent 772ed6bf8d
commit b08b52dd1b
10 changed files with 760 additions and 26 deletions
+389 -6
View File
@@ -1,21 +1,404 @@
import React from "react";
import React, { useEffect, useMemo, useState } from "react";
import ReactDOM from "react-dom/client";
import { createClient, type Session } from "@supabase/supabase-js";
import "./styles.css";
type BackendPrincipal = {
Subject: string;
Email: string;
Role: string;
};
type ViewKey = "overview" | "projects" | "vms" | "ssh" | "audit" | "console";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL ?? "";
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY ?? "";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
const supabase =
supabaseUrl && supabaseAnonKey
? createClient(supabaseUrl, supabaseAnonKey)
: undefined;
const navItems: Array<{ key: ViewKey; label: string }> = [
{ key: "overview", label: "Uebersicht" },
{ key: "projects", label: "Projekte" },
{ key: "vms", label: "VMs" },
{ key: "ssh", label: "SSH-Keys" },
{ key: "audit", label: "Audit" },
{ key: "console", label: "Konsole" },
];
function App() {
const [session, setSession] = useState<Session | null>(null);
const [loadingSession, setLoadingSession] = useState(true);
const [activeView, setActiveView] = useState<ViewKey>("overview");
const [backendPrincipal, setBackendPrincipal] =
useState<BackendPrincipal | null>(null);
const [backendStatus, setBackendStatus] = useState<
"idle" | "loading" | "ok" | "error"
>("idle");
const [backendError, setBackendError] = useState("");
useEffect(() => {
if (!supabase) {
setLoadingSession(false);
return;
}
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
setLoadingSession(false);
});
const { data } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setBackendPrincipal(null);
setBackendStatus("idle");
setBackendError("");
});
return () => data.subscription.unsubscribe();
}, []);
const connected = Boolean(session && backendPrincipal);
async function refreshBackendPrincipal() {
if (!session) {
return;
}
setBackendStatus("loading");
setBackendError("");
try {
const response = await fetch(`${apiBaseUrl}/me`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
throw new Error(`Backend antwortet mit ${response.status}`);
}
const principal = (await response.json()) as BackendPrincipal;
setBackendPrincipal(principal);
setBackendStatus("ok");
} catch (error) {
setBackendStatus("error");
setBackendError(error instanceof Error ? error.message : "Unbekannter Fehler");
}
}
if (loadingSession) {
return (
<main className="startup-screen">
<div className="loader" />
</main>
);
}
if (!supabase) {
return <MissingConfig />;
}
if (!session) {
return <AuthScreen />;
}
return (
<main className="app-shell">
<section className="panel">
<p className="eyebrow">ProxUI</p>
<h1>Proxmox Multi-Tenant Console</h1>
<main className="app-frame">
<aside className="sidebar">
<div className="brand-mark">P</div>
<nav className="main-nav" aria-label="Hauptnavigation">
{navItems.map((item) => (
<button
className={item.key === activeView ? "nav-item active" : "nav-item"}
key={item.key}
onClick={() => setActiveView(item.key)}
type="button"
>
{item.label}
</button>
))}
</nav>
</aside>
<section className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">ProxUI</p>
<h1>Proxmox Multi-Tenant Console</h1>
</div>
<div className="session-box">
<span className={connected ? "status-dot ok" : "status-dot"} />
<div>
<strong>{session.user.email}</strong>
<span>{connected ? "Backend verbunden" : "Session aktiv"}</span>
</div>
<button className="ghost-button" onClick={() => supabase.auth.signOut()}>
Abmelden
</button>
</div>
</header>
<section className="content-grid">
<BackendPanel
backendError={backendError}
backendPrincipal={backendPrincipal}
backendStatus={backendStatus}
onRefresh={refreshBackendPrincipal}
/>
<ViewPanel activeView={activeView} connected={connected} />
</section>
</section>
</main>
);
}
function MissingConfig() {
return (
<main className="startup-screen">
<section className="auth-panel">
<p className="eyebrow">Konfiguration</p>
<h1>Frontend Env fehlt</h1>
<p>
Grundgeruest fuer Login, Projekte, VM-Verwaltung und Web-Konsole.
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` muessen gesetzt sein.
</p>
</section>
</main>
);
}
function AuthScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"signin" | "signup">("signin");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setMessage("");
const result =
mode === "signin"
? await supabase!.auth.signInWithPassword({ email, password })
: await supabase!.auth.signUp({ email, password });
if (result.error) {
setMessage(result.error.message);
}
setBusy(false);
}
return (
<main className="auth-screen">
<section className="auth-panel">
<p className="eyebrow">ProxUI</p>
<h1>Anmelden</h1>
<form className="auth-form" onSubmit={submit}>
<label>
E-Mail
<input
autoComplete="email"
onChange={(event) => setEmail(event.target.value)}
required
type="email"
value={email}
/>
</label>
<label>
Passwort
<input
autoComplete={mode === "signin" ? "current-password" : "new-password"}
minLength={6}
onChange={(event) => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
{message ? <p className="form-error">{message}</p> : null}
<div className="auth-actions">
<button className="primary-button" disabled={busy} type="submit">
{mode === "signin" ? "Einloggen" : "Account erstellen"}
</button>
<button
className="ghost-button"
onClick={() => setMode(mode === "signin" ? "signup" : "signin")}
type="button"
>
{mode === "signin" ? "Registrieren" : "Zum Login"}
</button>
</div>
</form>
</section>
</main>
);
}
function BackendPanel({
backendError,
backendPrincipal,
backendStatus,
onRefresh,
}: {
backendError: string;
backendPrincipal: BackendPrincipal | null;
backendStatus: "idle" | "loading" | "ok" | "error";
onRefresh: () => void;
}) {
return (
<section className="panel backend-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Backend</p>
<h2>Profil-Sync</h2>
</div>
<button
className="primary-button"
disabled={backendStatus === "loading"}
onClick={onRefresh}
type="button"
>
Pruefen
</button>
</div>
<div className="metric-list">
<Metric label="Status" value={statusLabel(backendStatus)} />
<Metric label="User-ID" value={backendPrincipal?.Subject ?? "-"} />
<Metric label="Rolle" value={backendPrincipal?.Role ?? "-"} />
</div>
{backendError ? <p className="form-error">{backendError}</p> : null}
</section>
);
}
function ViewPanel({
activeView,
connected,
}: {
activeView: ViewKey;
connected: boolean;
}) {
const content = useMemo(() => viewContent(activeView), [activeView]);
return (
<section className="panel view-panel">
<div className="panel-header">
<div>
<p className="eyebrow">{content.state}</p>
<h2>{content.title}</h2>
</div>
<span className={connected ? "pill ok" : "pill"}>{content.badge}</span>
</div>
<div className="placeholder-table">
{content.rows.map((row) => (
<div className="placeholder-row" key={row.label}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function statusLabel(status: "idle" | "loading" | "ok" | "error") {
if (status === "loading") return "Pruefung laeuft";
if (status === "ok") return "Verbunden";
if (status === "error") return "Fehler";
return "Nicht geprueft";
}
function viewContent(activeView: ViewKey) {
const values: Record<
ViewKey,
{
badge: string;
rows: Array<{ label: string; value: string }>;
state: string;
title: string;
}
> = {
overview: {
badge: "Live",
state: "Status",
title: "Arbeitsbereich",
rows: [
{ label: "Auth", value: "angebunden" },
{ label: "Profil-Sync", value: "angebunden" },
{ label: "RLS", value: "aktiv" },
],
},
projects: {
badge: "Platzhalter",
state: "E2-T03",
title: "Projekte",
rows: [
{ label: "Schema", value: "projects" },
{ label: "Quotas", value: "project_quotas" },
{ label: "API", value: "offen" },
],
},
vms: {
badge: "Platzhalter",
state: "E2-T05",
title: "Virtuelle Maschinen",
rows: [
{ label: "Schema", value: "vms" },
{ label: "VMID-Allokator", value: "bereit" },
{ label: "Proxmox Client", value: "offen" },
],
},
ssh: {
badge: "Platzhalter",
state: "E2-T05",
title: "SSH-Keys",
rows: [
{ label: "Schema", value: "ssh_keys" },
{ label: "RLS", value: "aktiv" },
{ label: "Upload/API", value: "offen" },
],
},
audit: {
badge: "Platzhalter",
state: "E2-T07",
title: "Audit Log",
rows: [
{ label: "Schema", value: "audit_log" },
{ label: "Append-only", value: "aktiv" },
{ label: "Anzeige", value: "offen" },
],
},
console: {
badge: "Platzhalter",
state: "E8",
title: "Web-Konsole",
rows: [
{ label: "Proxy", value: "Grundgeruest" },
{ label: "Tickets", value: "offen" },
{ label: "noVNC/xterm", value: "offen" },
],
},
};
return values[activeView];
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />