LIC-04: dev-server fuer adminapi + next.js lizenz-modul-verwaltungsoberflaeche
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
export const metadata = {
|
||||
title: "NEXARCH Lizenz- und Modulverwaltung",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body style={{ fontFamily: "system-ui, sans-serif", margin: 0, background: "#f5f6f8" }}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fetchOverview, toggleFlag, type Overview } from "@/lib/api";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
ok: "In Ordnung",
|
||||
warning: "Nahe am Limit",
|
||||
exceeded: "Limit überschritten",
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
ok: "#2e7d32",
|
||||
warning: "#ed6c02",
|
||||
exceeded: "#c62828",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [tenantId, setTenantId] = useState("");
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | null>(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 (
|
||||
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1>Lizenz- und Modulverwaltung</h1>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||
<input
|
||||
value={tenantId}
|
||||
onChange={(e) => setTenantId(e.target.value)}
|
||||
placeholder="Tenant-ID eingeben"
|
||||
style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }}
|
||||
/>
|
||||
<button onClick={load} disabled={loading} style={{ padding: "0.5rem 1rem" }}>
|
||||
{loading ? "Lädt…" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
Fehler: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{overview && (
|
||||
<>
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||
<h2>Lizenzstatus</h2>
|
||||
<p>
|
||||
<strong>Plan:</strong> {overview.plan}{" "}
|
||||
{overview.expired && (
|
||||
<span style={{ color: "#c62828", fontWeight: "bold" }}>
|
||||
(abgelaufen)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Gültig bis:</strong>{" "}
|
||||
{new Date(overview.valid_until).toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Freigeschaltete Module:</strong>{" "}
|
||||
{overview.modules.length > 0 ? overview.modules.join(", ") : "keine"}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||
<h2>Feature-Flags</h2>
|
||||
{toggleError && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
{toggleError}
|
||||
</p>
|
||||
)}
|
||||
{overview.flags.length === 0 && <p>Keine Feature-Flags definiert.</p>}
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{overview.flags.map((f) => (
|
||||
<li
|
||||
key={f.key}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.5rem 0",
|
||||
borderBottom: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>{f.key}</div>
|
||||
{!f.licensed && f.deny_grund && (
|
||||
<div style={{ fontSize: "0.85rem", color: "#c62828" }}>
|
||||
{f.deny_grund}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.enabled}
|
||||
disabled={!f.licensed && !f.enabled}
|
||||
onChange={(e) => onToggle(f.key, e.target.checked)}
|
||||
/>{" "}
|
||||
aktiv
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8 }}>
|
||||
<h2>Nutzungsstand</h2>
|
||||
{overview.usage.map((u) => {
|
||||
const pct = u.limit > 0 ? Math.min(100, (u.value / u.limit) * 100) : 0;
|
||||
return (
|
||||
<div key={u.metric} style={{ marginBottom: "1rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span>{u.metric}</span>
|
||||
<span style={{ color: STATUS_COLOR[u.status] }}>
|
||||
{u.value} / {u.limit > 0 ? u.limit : "unbegrenzt"} —{" "}
|
||||
{STATUS_LABEL[u.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
background: "#eee",
|
||||
borderRadius: 4,
|
||||
height: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${u.limit > 0 ? pct : 0}%`,
|
||||
background: STATUS_COLOR[u.status],
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Overview> {
|
||||
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<void> {
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user