Compare commits

..

2 Commits

Author SHA1 Message Date
DanielS
3a090b7664 docs: document licserver management api
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-15 16:18:30 +02:00
DanielS
e2d92a031f feat(licserver): add partner customers proxy API 2026-07-15 16:14:31 +02:00
4 changed files with 328 additions and 77 deletions

View File

@@ -1,8 +1,8 @@
# LicServer API Spezifikation (v1)
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers (`http://192.168.178.174:9981`).
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers.
## 1. Endpunkte
## 1. Endpunkte (Port 9981 - Activation API)
### Activation (Aktivierung)
@@ -89,7 +89,119 @@ Standard Fehlerobjekt `ProblemDetails`.
---
## 2. Typen & Schemas
## 2. Endpunkte (Port 9980 - Management API)
Alle Anfragen an die Management-API müssen authentifiziert sein.
- **Header-Format:** `X-Api-Key: <Schlüssel>`
### Partners (Partner)
#### `GET /api-v1/partners`
Gibt eine paginierte Liste aller Partner zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{id}`
Holt Partner-Details zu einer bestimmten ID.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"email": "string",
"erpId": "string"
}
```
### Customers (Kunden von Partnern)
#### `GET /api-v1/partners/{partnerId}/customers`
Gibt eine paginierte Liste aller Kunden eines bestimmten Partners zurück.
**Request Parameters (Query):**
- `search` (string, optional): Filtert nach Name/E-Mail.
- `page` (integer, default: 1)
- `pageSize` (integer, default: 20)
**Response `200 OK` (`application/json`):**
```json
{
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1,
"hasNextPage": false,
"hasPreviousPage": false,
"items": [
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string"
}
]
}
```
#### `GET /api-v1/partners/{partnerId}/customers/{customerId}`
Holt detaillierte Kundeninformationen.
**Response `200 OK` (`application/json`):**
```json
{
"id": "string (UUID)",
"name": "string",
"street": "string",
"houseNumber": "string",
"houseNumberAddon": "string",
"zipCode": "string",
"city": "string",
"countryCode": "string",
"email": "string",
"partnerId": "string (UUID)",
"erpId": "string",
"partnerName": "string",
"taxNumber": "string",
"vatIdNumber": "string",
"notes": "string",
"locations": []
}
```
---
## 3. Typen & Schemas
### `LicenseData`
```json
@@ -112,3 +224,4 @@ Standard Fehlerobjekt `ProblemDetails`.
"instance": "string"
}
```

View File

@@ -21,14 +21,14 @@ export default function AdminSettings() {
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 [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(() => {
@@ -63,7 +63,7 @@ export default function AdminSettings() {
.single();
if (licRow) {
setLicUrl(licRow.licserver_base_url || '');
setLicKey(licRow.licserver_api_key || '');
setLicKey(licRow.licserver_api_key || '');
}
} catch (dbErr) {
console.error("Fehler beim Laden der LicServer-Einstellungen:", dbErr);
@@ -90,7 +90,7 @@ export default function AdminSettings() {
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -184,11 +184,10 @@ export default function AdminSettings() {
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
<div className={`p-4 rounded-lg text-sm border ${statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{statusMsg}
</div>
)}
@@ -205,8 +204,8 @@ export default function AdminSettings() {
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
</p>
</div>
<Button
onClick={handleExport}
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-primary hover:bg-primary/90 text-white"
>
@@ -235,9 +234,9 @@ export default function AdminSettings() {
<div className="space-y-3">
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
<input
type="file"
accept=".zip"
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
@@ -251,8 +250,8 @@ export default function AdminSettings() {
</div>
</div>
<Button
onClick={handleImport}
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="secondary"
className="w-full border border-slate-200 dark:border-white/10"
@@ -275,29 +274,26 @@ export default function AdminSettings() {
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.
Verbindungseinstellungen zum CASPOS Lizenzserver.
</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'
}`}>
<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'
}`}>
<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" />}
@@ -318,7 +314,7 @@ export default function AdminSettings() {
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>
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
</div>
{/* API Key */}
@@ -344,51 +340,51 @@ export default function AdminSettings() {
{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>
</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>
{/* 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>
<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

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { getLicServerConfig } from '@/lib/actions/licserver-config'
import type { CustomerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
/**
* GET /api/licserver/partners/[id]/customers
*
* Proxy to LicServer GET /api-v1/partners/{id}/customers
* [id] must be a valid UUID.
* Query params forwarded: search, page, pageSize
*
* 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,
{ params }: { params: Promise<{ id: string }> }
) {
// ── Auth guard ─────────────────────────────────────────────────────────
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ── 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 nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
{ status: 503 }
)
}
const { id } = await params
// Basic UUID format guard
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
}
// ── Forward query params ───────────────────────────────────────────────
const { searchParams } = req.nextUrl
const upstream = new URL(`${cfg.base_url}/api-v1/partners/${id}/customers`)
const search = searchParams.get('search')
const page = searchParams.get('page')
const pageSize = searchParams.get('pageSize')
if (search) upstream.searchParams.set('search', search)
if (page) upstream.searchParams.set('page', page)
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
// ── Proxy request ──────────────────────────────────────────────────────
try {
const res = await fetch(
upstream.toString(),
{
method: 'GET',
headers: {
'X-Api-Key': cfg.api_key,
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5_000),
}
)
if (!res.ok) {
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
return NextResponse.json(problem, { status: res.status })
}
const data: CustomerDtoPagedResult = await res.json()
return NextResponse.json(data)
} catch (err) {
console.error('[licserver] partner/:id/customers fetch error:', err)
return NextResponse.json(
{ error: 'LicServer nicht erreichbar' },
{ status: 502 }
)
}
}

View File

@@ -29,3 +29,60 @@ export interface LicServerProblemDetails {
instance?: string | null
[key: string]: unknown
}
export interface CustomerDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
}
export interface CustomerDtoPagedResult {
page: number
pageSize: number
totalCount: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
items: CustomerDto[] | null
}
export interface LocationDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
countryCode: string | null
postalCode: string | null
city: string | null
taxNumber: string | null
vatIdNumber: string | null
}
export interface CustomerDetailsDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
partnerName: string | null
taxNumber: string | null
vatIdNumber: string | null
notes: string | null
locations: LocationDto[] | null
}