From 0620baa9938087a819057ee4b3940714c7893f05 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:40:05 +0200 Subject: [PATCH] LIC-04: dev-server fuer adminapi + next.js lizenz-modul-verwaltungsoberflaeche --- cmd/licadmin-devserver/main.go | 81 +++++++++++++++ web/lic-admin/app/layout.tsx | 17 ++++ web/lic-admin/app/page.tsx | 173 +++++++++++++++++++++++++++++++++ web/lic-admin/lib/api.ts | 63 ++++++++++++ web/lic-admin/next.config.mjs | 3 + web/lic-admin/package.json | 21 ++++ web/lic-admin/tsconfig.json | 21 ++++ 7 files changed, 379 insertions(+) create mode 100644 cmd/licadmin-devserver/main.go create mode 100644 web/lic-admin/app/layout.tsx create mode 100644 web/lic-admin/app/page.tsx create mode 100644 web/lic-admin/lib/api.ts create mode 100644 web/lic-admin/next.config.mjs create mode 100644 web/lic-admin/package.json create mode 100644 web/lic-admin/tsconfig.json diff --git a/cmd/licadmin-devserver/main.go b/cmd/licadmin-devserver/main.go new file mode 100644 index 0000000..f585367 --- /dev/null +++ b/cmd/licadmin-devserver/main.go @@ -0,0 +1,81 @@ +// licadmin-devserver stellt das LIC-04-Backend-API (internal/adminapi) fuer +// die Next.js-Verwaltungsoberflaeche bereit. Getrennt von cmd/core, da die +// Verdrahtung von license.Validator (Ed25519-Public-Key) in die zentrale +// Server-Topologie erst mit API-01/IAM-01 (Auth vor internen Endpunkten) +// sinnvoll ist — dieser Server dient der Entwicklung/dem Betrieb der +// Verwaltungsoberflaeche gegen eine echte Datenbank, ohne cmd/core +// anzufassen (Kein Umbau angrenzender Bereiche). +package main + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "log" + "net/http" + "os" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/adminapi" + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/flag" + "gitea.perlbach24.de/scripte/nexarch/internal/license" + "gitea.perlbach24.de/scripte/nexarch/internal/usage" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + pubKeyB64 := os.Getenv("NEXARCH_LICENSE_PUBLIC_KEY") + if pubKeyB64 == "" { + log.Fatal("NEXARCH_LICENSE_PUBLIC_KEY nicht gesetzt (base64, raw-url-encoding)") + } + pubKeyRaw, err := base64.RawURLEncoding.DecodeString(pubKeyB64) + if err != nil || len(pubKeyRaw) != ed25519.PublicKeySize { + log.Fatalf("NEXARCH_LICENSE_PUBLIC_KEY ungueltig: %v", err) + } + + addr := os.Getenv("NEXARCH_LICADMIN_LISTEN_ADDR") + if addr == "" { + addr = ":8081" + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + licenseStore := license.NewStore(pool, license.NewValidator(ed25519.PublicKey(pubKeyRaw))) + flagStore := flag.NewStore(pool) + flagService := flag.NewService(flagStore, 5*time.Second) + usageStore := usage.NewStore(pool) + handler := adminapi.NewHandler(licenseStore, flagStore, flagService, usageStore) + + mux := http.NewServeMux() + mux.HandleFunc("/admin/overview", withCORS(handler.OverviewHandler)) + mux.HandleFunc("/admin/flags/toggle", withCORS(handler.ToggleFlagHandler)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("licadmin-devserver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +// withCORS erlaubt lokale Entwicklung des Next.js-Frontends auf einem +// anderen Port als das Backend-API — ausschliesslich fuer diesen +// Entwicklungsserver, keine Produktionshaertung. +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} diff --git a/web/lic-admin/app/layout.tsx b/web/lic-admin/app/layout.tsx new file mode 100644 index 0000000..30e77e0 --- /dev/null +++ b/web/lic-admin/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Lizenz- und Modulverwaltung", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/lic-admin/app/page.tsx b/web/lic-admin/app/page.tsx new file mode 100644 index 0000000..61c8f5a --- /dev/null +++ b/web/lic-admin/app/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import { fetchOverview, toggleFlag, type Overview } from "@/lib/api"; + +const STATUS_LABEL: Record = { + ok: "In Ordnung", + warning: "Nahe am Limit", + exceeded: "Limit überschritten", +}; + +const STATUS_COLOR: Record = { + ok: "#2e7d32", + warning: "#ed6c02", + exceeded: "#c62828", +}; + +export default function Page() { + const [tenantId, setTenantId] = useState(""); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [toggleError, setToggleError] = useState(null); + + async function load() { + if (!tenantId.trim()) return; + setLoading(true); + setError(null); + try { + const data = await fetchOverview(tenantId.trim()); + setOverview(data); + } catch (e: any) { + setError(e.message ?? "Unbekannter Fehler"); + setOverview(null); + } finally { + setLoading(false); + } + } + + async function onToggle(key: string, next: boolean) { + setToggleError(null); + try { + await toggleFlag(tenantId.trim(), key, next); + await load(); + } catch (e: any) { + setToggleError(e.message ?? "Umschalten fehlgeschlagen"); + } + } + + return ( +
+

Lizenz- und Modulverwaltung

+ +
+ setTenantId(e.target.value)} + placeholder="Tenant-ID eingeben" + style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }} + /> + +
+ + {error && ( +

+ Fehler: {error} +

+ )} + + {overview && ( + <> +
+

Lizenzstatus

+

+ Plan: {overview.plan}{" "} + {overview.expired && ( + + (abgelaufen) + + )} +

+

+ Gültig bis:{" "} + {new Date(overview.valid_until).toLocaleDateString("de-DE")} +

+

+ Freigeschaltete Module:{" "} + {overview.modules.length > 0 ? overview.modules.join(", ") : "keine"} +

+
+ +
+

Feature-Flags

+ {toggleError && ( +

+ {toggleError} +

+ )} + {overview.flags.length === 0 &&

Keine Feature-Flags definiert.

} +
    + {overview.flags.map((f) => ( +
  • +
    +
    {f.key}
    + {!f.licensed && f.deny_grund && ( +
    + {f.deny_grund} +
    + )} +
    + +
  • + ))} +
+
+ +
+

Nutzungsstand

+ {overview.usage.map((u) => { + const pct = u.limit > 0 ? Math.min(100, (u.value / u.limit) * 100) : 0; + return ( +
+
+ {u.metric} + + {u.value} / {u.limit > 0 ? u.limit : "unbegrenzt"} —{" "} + {STATUS_LABEL[u.status]} + +
+
+
0 ? pct : 0}%`, + background: STATUS_COLOR[u.status], + height: "100%", + }} + /> +
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/web/lic-admin/lib/api.ts b/web/lic-admin/lib/api.ts new file mode 100644 index 0000000..63b1e3d --- /dev/null +++ b/web/lic-admin/lib/api.ts @@ -0,0 +1,63 @@ +// Duenner Client fuer das LIC-04-Backend-API (internal/adminapi) — enthaelt +// keinerlei eigene Lizenz-/Flag-Logik, nur Datenabruf und -weitergabe. +export type FlagOverview = { + key: string; + enabled: boolean; + licensed: boolean; + deny_grund?: string; +}; + +export type UsageOverview = { + metric: string; + value: number; + limit: number; + status: "ok" | "warning" | "exceeded"; +}; + +export type Overview = { + plan: string; + modules: string[]; + issued_at: string; + valid_until: string; + expired: boolean; + flags: FlagOverview[]; + usage: UsageOverview[]; +}; + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_ADMIN_API_URL; + if (!base) { + throw new Error( + "NEXT_PUBLIC_ADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)" + ); + } + return base; +} + +export async function fetchOverview(tenantId: string): Promise { + const res = await fetch( + `${apiBase()}/admin/overview?tenant=${encodeURIComponent(tenantId)}`, + { cache: "no-store" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`); + } + return res.json(); +} + +export async function toggleFlag( + tenantId: string, + key: string, + enabled: boolean +): Promise { + const res = await fetch(`${apiBase()}/admin/flags/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tenant: tenantId, key, enabled }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Umschalten fehlgeschlagen (${res.status})`); + } +} diff --git a/web/lic-admin/next.config.mjs b/web/lic-admin/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/lic-admin/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/lic-admin/package.json b/web/lic-admin/package.json new file mode 100644 index 0000000..79a1ea2 --- /dev/null +++ b/web/lic-admin/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-lic-admin", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.5", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/lic-admin/tsconfig.json b/web/lic-admin/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/lic-admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}