feat(admin): LicServer config manageable via admin settings UI
Some checks failed
Staging Build / build (push) Has been cancelled
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:
@@ -4,8 +4,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { createClient } from '@/lib/supabase/client';
|
||||||
|
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
@@ -16,6 +19,16 @@ export default function AdminSettings() {
|
|||||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +50,18 @@ export default function AdminSettings() {
|
|||||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||||
setDemoActive(state);
|
setDemoActive(state);
|
||||||
setLoading(false);
|
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();
|
checkAccess();
|
||||||
@@ -232,6 +257,128 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||||
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
|
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]
|
* GET /api/licserver/partners/[id]
|
||||||
*
|
*
|
||||||
@@ -12,6 +10,8 @@ const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
|
|||||||
* [id] must be a valid UUID.
|
* [id] must be a valid UUID.
|
||||||
*
|
*
|
||||||
* Secured: requires a valid Supabase session.
|
* 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(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
@@ -24,11 +24,12 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Env check ──────────────────────────────────────────────────────────
|
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||||
if (!LICSERVER_BASE_URL || !LICSERVER_API_KEY) {
|
const cfg = await getLicServerConfig()
|
||||||
console.error('[licserver] LICSERVER_BASE_URL or LICSERVER_API_KEY not set')
|
if (!cfg.base_url || !cfg.api_key) {
|
||||||
|
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'LicServer not configured' },
|
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -44,11 +45,11 @@ export async function GET(
|
|||||||
// ── Proxy request ──────────────────────────────────────────────────────
|
// ── Proxy request ──────────────────────────────────────────────────────
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${LICSERVER_BASE_URL}/api-v1/partners/${id}`,
|
`${cfg.base_url}/api-v1/partners/${id}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': LICSERVER_API_KEY,
|
'X-Api-Key': cfg.api_key,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||||
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
|
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
|
* GET /api/licserver/partners
|
||||||
*
|
*
|
||||||
@@ -12,7 +10,8 @@ const LICSERVER_API_KEY = process.env.LICSERVER_API_KEY
|
|||||||
* Query params forwarded: search, page, pageSize
|
* Query params forwarded: search, page, pageSize
|
||||||
*
|
*
|
||||||
* Secured: requires a valid Supabase session.
|
* 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) {
|
export async function GET(req: NextRequest) {
|
||||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||||
@@ -22,18 +21,19 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Env check ──────────────────────────────────────────────────────────
|
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||||
if (!LICSERVER_BASE_URL || !LICSERVER_API_KEY) {
|
const cfg = await getLicServerConfig()
|
||||||
console.error('[licserver] LICSERVER_BASE_URL or LICSERVER_API_KEY not set')
|
if (!cfg.base_url || !cfg.api_key) {
|
||||||
|
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'LicServer not configured' },
|
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Forward query params ───────────────────────────────────────────────
|
// ── Forward query params ───────────────────────────────────────────────
|
||||||
const { searchParams } = req.nextUrl
|
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 search = searchParams.get('search')
|
||||||
const page = searchParams.get('page')
|
const page = searchParams.get('page')
|
||||||
const pageSize = searchParams.get('pageSize')
|
const pageSize = searchParams.get('pageSize')
|
||||||
@@ -46,7 +46,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const res = await fetch(upstream.toString(), {
|
const res = await fetch(upstream.toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': LICSERVER_API_KEY,
|
'X-Api-Key': cfg.api_key,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
// Internal container network — short timeout
|
// Internal container network — short timeout
|
||||||
|
|||||||
86
shop/lib/actions/licserver-config.ts
Normal file
86
shop/lib/actions/licserver-config.ts
Normal 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}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user