feat: add frontend prototype
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Frontend-Prototyp mit Supabase Auth, Backend-Profilcheck und Platzhalter-Views angelegt.
|
||||||
- Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt.
|
- Zentrale RBAC-Policy-Funktion mit Rollen/Aktions-Matrix angelegt.
|
||||||
- Lazy Profil-Sync vom Supabase-JWT nach `public.profiles` im Backend 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.
|
- Backend-JWT-Middleware mit JWKS-Validierung, lokalem HS256-Fallback und geschuetztem `/me` Endpunkt angelegt.
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ Backend-Endpunkte:
|
|||||||
- `GET /healthz`: oeffentlicher Healthcheck
|
- `GET /healthz`: oeffentlicher Healthcheck
|
||||||
- `GET /me`: geschuetzt, synchronisiert `profiles` und gibt den authentifizierten Principal aus dem JWT zurueck
|
- `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
|
## CI
|
||||||
|
|
||||||
Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`.
|
Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`.
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
|
|||||||
- [x] `Can(role, action)` angelegt
|
- [x] `Can(role, action)` angelegt
|
||||||
- [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt
|
- [x] Tabellengetriebene Unit-Tests fuer Rollen/Aktionen angelegt
|
||||||
- [x] `cluster.manage` fuer Tenant-Rollen bewusst verweigert
|
- [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
|
## 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: 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: 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: RBAC-Policy-Funktion mit tabellengetriebenen Rollen/Aktions-Tests erfolgreich.
|
||||||
|
- 2026-06-10: Frontend-Prototyp mit Supabase Auth und Backend-`/me` Check erfolgreich gebaut.
|
||||||
|
|||||||
+20
-1
@@ -62,7 +62,7 @@ func main() {
|
|||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.BackendAddr,
|
Addr: cfg.BackendAddr,
|
||||||
Handler: mux,
|
Handler: withCORS(cfg.AppSiteURL, mux),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +103,22 @@ func openDatabase(databaseURL string) (*sql.DB, error) {
|
|||||||
|
|
||||||
return db, nil
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+389
-6
@@ -1,21 +1,404 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { createClient, type Session } from "@supabase/supabase-js";
|
||||||
import "./styles.css";
|
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() {
|
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 (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-frame">
|
||||||
<section className="panel">
|
<aside className="sidebar">
|
||||||
<p className="eyebrow">ProxUI</p>
|
<div className="brand-mark">P</div>
|
||||||
<h1>Proxmox Multi-Tenant Console</h1>
|
<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>
|
<p>
|
||||||
Grundgeruest fuer Login, Projekte, VM-Verwaltung und Web-Konsole.
|
`VITE_SUPABASE_URL` und `VITE_SUPABASE_ANON_KEY` muessen gesetzt sein.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
+335
-18
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #172026;
|
color: #172026;
|
||||||
background: #f6f8fb;
|
background: #f4f7fb;
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -14,37 +14,354 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.startup-screen,
|
||||||
|
.auth-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.loader {
|
||||||
width: min(720px, 100%);
|
width: 32px;
|
||||||
border: 1px solid #d7dee8;
|
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;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
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 {
|
.eyebrow {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 8px;
|
||||||
color: #1f6f8b;
|
color: #147d64;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.session-box {
|
||||||
margin: 0;
|
max-width: 520px;
|
||||||
font-size: 32px;
|
min-width: 280px;
|
||||||
line-height: 1.15;
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #d6dee8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
.session-box div {
|
||||||
font-size: 16px;
|
min-width: 0;
|
||||||
line-height: 1.6;
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/main.tsx"],"version":"5.9.3"}
|
|
||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
SupabaseIssuer string
|
SupabaseIssuer string
|
||||||
SupabaseJWTSecret string
|
SupabaseJWTSecret string
|
||||||
RedisAddr string
|
RedisAddr string
|
||||||
|
AppSiteURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -50,6 +51,7 @@ func Load() (Config, error) {
|
|||||||
SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"),
|
SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"),
|
||||||
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
||||||
|
AppSiteURL: getenv("APP_SITE_URL", "http://localhost:5173"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"SUPABASE_ISSUER",
|
"SUPABASE_ISSUER",
|
||||||
"SUPABASE_JWT_SECRET",
|
"SUPABASE_JWT_SECRET",
|
||||||
"REDIS_ADDR",
|
"REDIS_ADDR",
|
||||||
|
"APP_SITE_URL",
|
||||||
} {
|
} {
|
||||||
t.Setenv(key, "")
|
t.Setenv(key, "")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user