feat(admin): LicServer config manageable via admin settings UI
Some checks failed
Staging Build / build (push) Has been cancelled

- Migration: add licserver_base_url/api_key cols to settings table
- New: lib/actions/licserver-config.ts (read/write/test server actions)
- Updated: proxy routes read config from DB with env-var fallback
- Updated: admin/einstellungen adds LicServer card with URL + API key
  input, show/hide toggle, Save and Connection Test buttons
This commit is contained in:
DanielS
2026-07-14 17:30:37 +02:00
parent be4e9204a3
commit fa93e060c2
5 changed files with 265 additions and 20 deletions

View File

@@ -4,8 +4,11 @@
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Download, Upload, Database, AlertTriangle, Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
import { useRouter } from 'next/navigation';
export default function AdminSettings() {
@@ -16,6 +19,16 @@ export default function AdminSettings() {
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
// LicServer Config State
const [licUrl, setLicUrl] = useState('');
const [licKey, setLicKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [licSaving, setLicSaving] = useState(false);
const [licTesting, setLicTesting] = useState(false);
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [licMsg, setLicMsg] = useState('');
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
@@ -37,6 +50,18 @@ export default function AdminSettings() {
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
setLoading(false);
// Load current LicServer config from DB
const supabaseClient = createClient();
const { data: licRow } = await supabaseClient
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single();
if (licRow) {
setLicUrl(licRow.licserver_base_url || '');
setLicKey(licRow.licserver_api_key || '');
}
}
}
checkAccess();
@@ -232,6 +257,128 @@ export default function AdminSettings() {
</div>
</div>
</div>
{/* LicServer Konfiguration */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<KeyRound className="w-5 h-5 text-violet-500" />
LicServer Konfiguration
</h2>
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
Verbindungseinstellungen zum CASPOS Lizenzserver (internes Container-Netz).
Der API-Key wird verschlüsselt in der Datenbank gespeichert und nie an den Browser übertragen.
</p>
</div>
{/* Status Message */}
{licMsg && (
<div className={`p-3 rounded-lg text-sm border ${
licMsgType === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{licMsg}
</div>
)}
{/* Connection Test Result */}
{licStatus && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm border ${
licStatus.ok
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
}`}>
{licStatus.ok
? <CheckCircle2 className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />}
{licStatus.message}
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="lic-url" className="text-xs font-medium flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
</Label>
<Input
id="lic-url"
value={licUrl}
onChange={e => setLicUrl(e.target.value)}
placeholder="http://192.168.178.174:9980"
className="text-sm font-mono"
/>
<p className="text-[10px] text-slate-400">Interne Container-URL ohne trailing slash</p>
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="lic-key" className="text-xs font-medium flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
</Label>
<div className="relative">
<Input
id="lic-key"
type={showKey ? 'text' : 'password'}
value={licKey}
onChange={e => setLicKey(e.target.value)}
placeholder="Ihr X-Api-Key"
className="text-sm font-mono pr-10"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-[10px] text-slate-400">Wird als X-Api-Key Header an den LicServer gesendet</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-1">
<Button
id="lic-save-btn"
disabled={licSaving || licTesting}
onClick={async () => {
setLicSaving(true);
setLicMsg('');
setLicStatus(null);
const res = await saveLicServerConfig(licUrl, licKey);
setLicMsgType(res.success ? 'success' : 'error');
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
setLicSaving(false);
}}
className="bg-violet-600 hover:bg-violet-500 text-white"
>
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
Speichern
</Button>
<Button
id="lic-test-btn"
variant="outline"
disabled={licSaving || licTesting}
onClick={async () => {
setLicTesting(true);
setLicStatus(null);
// Save first, then test
await saveLicServerConfig(licUrl, licKey);
const result = await testLicServerConnection();
setLicStatus(result);
setLicTesting(false);
}}
className="border-white/10"
>
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
Verbindung testen
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
const LICSERVER_BASE_URL = process.env.LICSERVER_BASE_URL
const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
/**
* GET /api/licserver/partners/[id]
*
@@ -12,6 +10,8 @@ const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
* [id] must be a valid UUID.
*
* Secured: requires a valid Supabase session.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(
_req: NextRequest,
@@ -24,11 +24,12 @@ export async function GET(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Env check ──────────────────────────────────────────────────────────
if (!LICSERVER_BASE_URL || !LICSERVER_API_KEY) {
console.error('[licserver] LICSERVER_BASE_URL or LICSERVER_API_KEY not set')
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer not configured' },
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
@@ -44,11 +45,11 @@ export async function GET(
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
`${LICSERVER_BASE_URL}/api-v1/partners/${id}`,
`${cfg.base_url}/api-v1/partners/${id}`,
{
method: 'GET',
headers: {
'X-Api-Key': LICSERVER_API_KEY,
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),

View File

@@ -1,10 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
const LICSERVER_BASE_URL = process.env.LICSERVER_BASE_URL
const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
/**
* GET /api/licserver/partners
*
@@ -12,7 +10,8 @@ const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
* Query params forwarded: search, page, pageSize
*
* Secured: requires a valid Supabase session.
* The X-Api-Key is injected server-side and never exposed to the browser.
* base_url + api_key are read from DB settings (admin-configurable),
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
*/
export async function GET(req: NextRequest) {
// ── Auth guard ─────────────────────────────────────────────────────────
@@ -22,18 +21,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── Env check ──────────────────────────────────────────────────────────
if (!LICSERVER_BASE_URL || !LICSERVER_API_KEY) {
console.error('[licserver] LICSERVER_BASE_URL or LICSERVER_API_KEY not set')
// ── Load config from DB (falls back to env vars) ──────────────────────
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
console.error('[licserver] base_url or api_key not configured (DB or env)')
return NextResponse.json(
{ error: 'LicServer not configured' },
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${LICSERVER_BASE_URL}/api-v1/partners`)
const upstream = new URL(`${cfg.base_url}/api-v1/partners`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
@@ -46,7 +46,7 @@ export async function GET(req: NextRequest) {
const res = await fetch(upstream.toString(), {
method: 'GET',
headers: {
'X-Api-Key': LICSERVER_API_KEY,
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
// Internal container network — short timeout

View File

@@ -0,0 +1,86 @@
'use server'
import { createClient } from '@/lib/supabase/server'
export interface LicServerConfig {
base_url: string | null
api_key: string | null
}
/**
* Read LicServer config from the settings table (admin only).
* Falls back to environment variables if DB values are empty.
*/
export async function getLicServerConfig(): Promise<LicServerConfig> {
const supabase = await createClient()
const { data } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single()
return {
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
}
}
/**
* Save LicServer config. Admin-only — checks role before writing.
*/
export async function saveLicServerConfig(
base_url: string,
api_key: string
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
// Auth + role check
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: 'Nicht angemeldet' }
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (!userData || !['admin', 'superadmin'].includes(userData.role)) {
return { success: false, error: 'Keine Berechtigung' }
}
const { error } = await supabase
.from('settings')
.upsert({
id: 'licserver',
licserver_base_url: base_url.trim() || null,
licserver_api_key: api_key.trim() || null,
updated_at: new Date().toISOString(),
})
if (error) return { success: false, error: error.message }
return { success: true }
}
/**
* Test the connection with current config.
* Calls GET /api-v1/partners?pageSize=1 and returns true if 200.
*/
export async function testLicServerConnection(): Promise<{ ok: boolean; message: string }> {
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
return { ok: false, message: 'URL oder API-Key nicht konfiguriert' }
}
try {
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1`, {
headers: { 'X-Api-Key': cfg.api_key, Accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
})
if (res.ok) return { ok: true, message: `Verbindung OK (HTTP ${res.status})` }
return { ok: false, message: `LicServer antwortete mit HTTP ${res.status}` }
} catch (err: any) {
return { ok: false, message: `Nicht erreichbar: ${err.message}` }
}
}

View File

@@ -0,0 +1,11 @@
-- Migration: Add LicServer configuration columns to settings table
ALTER TABLE public.settings
ADD COLUMN IF NOT EXISTS licserver_base_url TEXT,
ADD COLUMN IF NOT EXISTS licserver_api_key TEXT;
-- Ensure the licserver config row exists (upsert with empty defaults)
INSERT INTO public.settings (id, licserver_base_url, licserver_api_key)
VALUES ('licserver', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
NOTIFY pgrst, 'reload schema';