diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cb683..730fdf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt. - Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt. - Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend angelegt. - Backend-JWT-Middleware mit JWKS-Validierung, lokalem HS256-Fallback und geschuetztem `/me` Endpunkt angelegt. diff --git a/README.md b/README.md index 67d9553..461b868 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ Backend-Endpunkte: - `GET /healthz`: oeffentlicher Healthcheck - `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT zurueck +Frontend-Prototyp: + +- Supabase Login/Registrierung ueber `VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` +- Backend-Profilcheck ueber `GET /me` +- Platzhalter-Ansichten fuer Projekte, VMs, SSH-Keys, Audit und Konsole + ## CI Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`. diff --git a/TODO.md b/TODO.md index 1f9782c..d25c9aa 100644 --- a/TODO.md +++ b/TODO.md @@ -87,6 +87,10 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] `Can(role, action)` angelegt - [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt - [x] `cluster.manage` fuer Tenant-Rollen bewusst verweigert +- [x] E10-Vorgriff: Frontend-Prototyp + - [x] Supabase Auth Login/Registrierung angebunden + - [x] Backend-`/me` Profil-Sync-Pruefung angebunden + - [x] App-Shell mit Platzhalter-Views fuer Projekte, VMs, SSH-Keys, Audit und Konsole angelegt ## MVP-Backlog @@ -137,3 +141,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-10: JWT-Middleware-Tests fuer gueltige, abgelaufene, manipulierte und fehlende Tokens erfolgreich. - 2026-06-10: Profil-Sync-Tests erfolgreich; lokaler `/me` Request legt genau ein `profiles`-Profil an. - 2026-06-10: RBAC-Policy-Funktion mit tabellengetriebenen Rollen/Aktions-Tests erfolgreich. +- 2026-06-10: Frontend-Prototyp mit Supabase Auth und Backend-`/me` Check erfolgreich gebaut. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index d1cd169..28def34 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -62,7 +62,7 @@ func main() { server := &http.Server{ Addr: cfg.BackendAddr, - Handler: mux, + Handler: withCORS(cfg.AppSiteURL, mux), ReadHeaderTimeout: 5 * time.Second, } @@ -103,3 +103,22 @@ func openDatabase(databaseURL string) (*sql.DB, error) { return db, nil } + +func withCORS(allowedOrigin string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && origin == allowedOrigin { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Vary", "Origin") + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f65d4b4..364bbf8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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(null); + const [loadingSession, setLoadingSession] = useState(true); + const [activeView, setActiveView] = useState("overview"); + const [backendPrincipal, setBackendPrincipal] = + useState(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 ( +
+
+
+ ); + } + + if (!supabase) { + return ; + } + + if (!session) { + return ; + } + return ( -
-
-

ProxUI

-

Proxmox Multi-Tenant Console

+
+ + +
+
+
+

ProxUI

+

Proxmox Multi-Tenant Console

+
+
+ +
+ {session.user.email} + {connected ? "Backend verbunden" : "Session aktiv"} +
+ +
+
+ +
+ + +
+
+
+ ); +} + +function MissingConfig() { + return ( +
+
+

Konfiguration

+

Frontend Env fehlt

- Grundgeruest fuer Login, Projekte, VM-Verwaltung und Web-Konsole. + `VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` muessen gesetzt sein.

); } +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) { + 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 ( +
+
+

ProxUI

+

Anmelden

+
+ + + {message ?

{message}

: null} +
+ + +
+
+
+
+ ); +} + +function BackendPanel({ + backendError, + backendPrincipal, + backendStatus, + onRefresh, +}: { + backendError: string; + backendPrincipal: BackendPrincipal | null; + backendStatus: "idle" | "loading" | "ok" | "error"; + onRefresh: () => void; +}) { + return ( +
+
+
+

Backend

+

Profil-Sync

+
+ +
+
+ + + +
+ {backendError ?

{backendError}

: null} +
+ ); +} + +function ViewPanel({ + activeView, + connected, +}: { + activeView: ViewKey; + connected: boolean; +}) { + const content = useMemo(() => viewContent(activeView), [activeView]); + + return ( +
+
+
+

{content.state}

+

{content.title}

+
+ {content.badge} +
+
+ {content.rows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +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( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 24f510d..1de79a7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,6 +1,6 @@ :root { color: #172026; - background: #f6f8fb; + background: #f4f7fb; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -14,37 +14,354 @@ body { margin: 0; } -.app-shell { +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.startup-screen, +.auth-screen { min-height: 100vh; display: grid; place-items: center; - padding: 32px; + padding: 24px; } -.panel { - width: min(720px, 100%); - border: 1px solid #d7dee8; +.loader { + width: 32px; + height: 32px; + border: 3px solid #cfdae7; + border-top-color: #147d64; + border-radius: 999px; + animation: spin 900ms linear infinite; +} + +.auth-panel { + width: min(420px, 100%); + border: 1px solid #d6dee8; border-radius: 8px; background: #ffffff; - padding: 32px; + padding: 28px; + box-shadow: 0 18px 50px rgba(23, 32, 38, 0.08); +} + +.auth-panel h1, +.topbar h1, +.panel h2 { + margin: 0; + line-height: 1.15; + letter-spacing: 0; +} + +.auth-panel h1 { + font-size: 30px; +} + +.auth-form { + display: grid; + gap: 16px; + margin-top: 24px; +} + +.auth-form label { + display: grid; + gap: 8px; + color: #4d5c68; + font-size: 14px; + font-weight: 700; +} + +.auth-form input { + width: 100%; + border: 1px solid #c7d2de; + border-radius: 6px; + color: #172026; + background: #ffffff; + padding: 11px 12px; +} + +.auth-actions, +.panel-header, +.session-box { + display: flex; + align-items: center; +} + +.auth-actions { + gap: 10px; + flex-wrap: wrap; +} + +.primary-button, +.ghost-button { + min-height: 38px; + border-radius: 6px; + padding: 0 14px; + font-weight: 700; +} + +.primary-button { + border: 1px solid #147d64; + color: #ffffff; + background: #147d64; +} + +.primary-button:disabled { + opacity: 0.58; + cursor: progress; +} + +.ghost-button { + border: 1px solid #c7d2de; + color: #23313a; + background: #ffffff; +} + +.form-error { + margin: 0; + color: #a13c21; + font-size: 14px; + line-height: 1.45; +} + +.app-frame { + min-height: 100vh; + display: grid; + grid-template-columns: 224px minmax(0, 1fr); +} + +.sidebar { + border-right: 1px solid #dbe3ec; + background: #ffffff; + padding: 20px 14px; +} + +.brand-mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 8px; + color: #ffffff; + background: #147d64; + font-weight: 800; +} + +.main-nav { + display: grid; + gap: 6px; + margin-top: 28px; +} + +.nav-item { + width: 100%; + border: 0; + border-radius: 6px; + color: #40515d; + background: transparent; + padding: 10px 12px; + text-align: left; +} + +.nav-item.active { + color: #0f2c26; + background: #e5f4ef; + font-weight: 800; +} + +.workspace { + min-width: 0; + padding: 28px; +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 24px; +} + +.topbar h1 { + font-size: 28px; } .eyebrow { - margin: 0 0 12px; - color: #1f6f8b; - font-size: 14px; - font-weight: 700; + margin: 0 0 8px; + color: #147d64; + font-size: 12px; + font-weight: 800; letter-spacing: 0; text-transform: uppercase; } -h1 { - margin: 0; - font-size: 32px; - line-height: 1.15; +.session-box { + max-width: 520px; + min-width: 280px; + justify-content: flex-end; + gap: 12px; + border: 1px solid #d6dee8; + border-radius: 8px; + background: #ffffff; + padding: 10px; } -p { - font-size: 16px; - line-height: 1.6; +.session-box div { + min-width: 0; +} + +.session-box strong, +.session-box span { + display: block; +} + +.session-box strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.session-box span { + color: #60717d; + font-size: 13px; +} + +.status-dot { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 999px; + background: #d99b33; +} + +.status-dot.ok { + background: #147d64; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr); + gap: 18px; +} + +.panel { + min-width: 0; + border: 1px solid #d6dee8; + border-radius: 8px; + background: #ffffff; + padding: 22px; +} + +.panel-header { + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.panel h2 { + font-size: 21px; +} + +.metric-list, +.placeholder-table { + display: grid; + gap: 10px; +} + +.metric, +.placeholder-row { + display: flex; + justify-content: space-between; + gap: 16px; + border: 1px solid #edf1f5; + border-radius: 6px; + padding: 12px; +} + +.metric span, +.placeholder-row span { + color: #60717d; +} + +.metric strong, +.placeholder-row strong { + min-width: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.pill { + border: 1px solid #d7a44d; + border-radius: 999px; + color: #8a5c0b; + background: #fff8e8; + padding: 5px 10px; + font-size: 12px; + font-weight: 800; +} + +.pill.ok { + border-color: #6fb79f; + color: #126b55; + background: #e8f6f1; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 900px) { + .app-frame { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid #dbe3ec; + } + + .main-nav { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .topbar, + .content-grid { + grid-template-columns: 1fr; + flex-direction: column; + } + + .session-box { + width: 100%; + min-width: 0; + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .workspace { + padding: 18px; + } + + .main-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .panel-header, + .metric, + .placeholder-row { + align-items: flex-start; + flex-direction: column; + } + + .metric strong, + .placeholder-row strong { + text-align: left; + } } diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo deleted file mode 100644 index d682d09..0000000 --- a/frontend/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/main.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/platform/config/config.go b/platform/config/config.go index 219ebf6..601330b 100644 --- a/platform/config/config.go +++ b/platform/config/config.go @@ -21,6 +21,7 @@ type Config struct { SupabaseIssuer string SupabaseJWTSecret string RedisAddr string + AppSiteURL string } func Load() (Config, error) { @@ -50,6 +51,7 @@ func Load() (Config, error) { SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), + AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"), }, nil } diff --git a/platform/config/config_test.go b/platform/config/config_test.go index 5fe356a..89d0b9d 100644 --- a/platform/config/config_test.go +++ b/platform/config/config_test.go @@ -104,6 +104,7 @@ func clearConfigEnv(t *testing.T) { "SUPABASE_ISSUER", "SUPABASE_JWT_SECRET", "REDIS_ADDR", + "APP_SITE_URL", } { t.Setenv(key, "") }