Lizenzverwaltung: Übersicht, Vergabe, Aktivierungen, Lebenszyklus und Kundensicht

Neuer Menüpunkt "Lizenzen" (Personal) bzw. "Meine Lizenzen" (Kunden):
- Übersicht mit Kennzahlen, Filtern (aktiv, läuft in 30 Tagen ab, gesperrt,
  abgelaufen, ohne Kunde) und Suche; Dashboard-Kachel
- Vergabe mit Vertrag (normaler Bestellweg) oder ohne Berechnung direkt im
  Lizenzsystem: Lizenz, Test (Trial) oder Add-on zu einer Basislizenz
- Detailseite: Schlüssel, Geräte (Aktivierungen) freigeben, Sperren/Entsperren,
  Verlängern, Widerrufen, Limits, Funktionsumfang (Entitlement), Produktwechsel
  bzw. Testumwandlung, Add-ons, Verlauf
- Kunden-Selbstbedienung: Inhaber/Admin geben eigene Geräte frei

Verwaltungs-Client für das Lizenzsystem in @kc/connector-licensing (createLicensingAdmin),
Fehlermeldungen des Lizenzsystems werden verständlich weitergereicht. Alle Änderungen
im Audit-Protokoll; der lokale Stand wird danach sofort aktualisiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-10-02 12:53:20 +02:00
parent 9336f46d0a
commit a322166d01
11 changed files with 784 additions and 31 deletions

View file

@ -0,0 +1,110 @@
import { ConnectorError, type ConnectorContext, type NormalizedResource } from '@kc/connector-sdk';
import { mapLicense, type RawActivation, type RawGroup, type RawLicense, type RawProgram } from './index.js';
/**
* Verwaltungs-Client für die Lizenzverwaltung im Kundencenter (Übersicht, Vergabe, Aktivierungen, Entitlements,
* Lebenszyklus). Ergänzt den Connector, der nur den allgemeinen Ressourcen-Vertrag abdeckt. Benötigt einen
* Service-Token (Scopes licenses:*, activations:reset, programs:read, products:read) oder API-Benutzer.
* Fehlermeldungen des Lizenzsystems (detail) werden als ConnectorError INVALID_INPUT/CONFLICT weitergereicht,
* damit die Oberfläche sie anzeigen kann (z. B. "Trial existiert bereits").
*/
export interface LicenseActivation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string; active: boolean }
export interface LicenseDetail {
resource: NormalizedResource; raw: RawLicense; activations: LicenseActivation[];
entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number };
limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null };
origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null };
}
export interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: 'LICENSED' | 'ADDON' | string; durationType: string; price: string | null; currency: string | null; planKey: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; active: boolean }
export interface LicensingCatalog { programs: { id: number; name: string }[]; products: CatalogProduct[] }
const mask = (v: string | null | undefined) => (v ? (v.length <= 8 ? '****' : `${v.slice(0, 4)}…${v.slice(-4)}`) : null);
const tokenCache = new Map<string, { token: string; at: number }>();
const splitModules = (v: string | null | undefined): string[] => String(v ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
export function createLicensingAdmin(ctx: ConnectorContext, fetchImpl: typeof fetch = fetch) {
const base = String(ctx.config.baseUrl ?? '').replace(/\/+$/, '');
if (!/^https?:\/\//.test(base)) throw new ConnectorError('BAD_CONFIG', 'baseUrl fehlt');
if (!ctx.secrets.token && !(ctx.secrets.username && ctx.secrets.password)) throw new ConnectorError('UNSUPPORTED', 'weder Service-Token noch API-Benutzer konfiguriert');
async function auth(force = false): Promise<string> {
if (ctx.secrets.token) return `Bearer ${ctx.secrets.token}`;
const key = `${base}|${ctx.secrets.username}|${ctx.secrets.password}`; const c = tokenCache.get(key);
if (!force && c && Date.now() - c.at < 20 * 60_000) return `Bearer ${c.token}`;
const r = await fetchImpl(`${base}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: ctx.secrets.username!, password: ctx.secrets.password! }).toString() });
const j = await r.json().catch(() => null) as { access_token?: string } | null;
if (!r.ok || !j?.access_token) throw new ConnectorError('AUTH_FAILED', `HTTP ${r.status}`);
tokenCache.set(key, { token: j.access_token, at: Date.now() });
return `Bearer ${j.access_token}`;
}
/** Fehlertext des Lizenzsystems (FastAPI: detail als Text, Objekt oder Liste von Validierungsfehlern). */
const detailOf = (b: unknown): string | undefined => {
const d = (b as { detail?: unknown } | null)?.detail;
if (typeof d === 'string') return d.slice(0, 300);
if (Array.isArray(d)) return d.map((x) => `${Array.isArray(x?.loc) ? x.loc.slice(1).join('.') : ''}: ${x?.msg ?? ''}`).join('; ').slice(0, 300);
if (d && typeof d === 'object' && typeof (d as { message?: unknown }).message === 'string') return String((d as { message: string }).message).slice(0, 300);
return undefined;
};
async function call<T>(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown, headers: Record<string, string> = {}, retried = false): Promise<T> {
const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 15_000);
let res: Response;
try {
res = await fetchImpl(`${base}${path}`, { method, signal: ctl.signal, headers: { accept: 'application/json', authorization: await auth(), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined });
} catch (e) {
throw (e as { name?: string }).name === 'AbortError' ? new ConnectorError('TIMEOUT') : new ConnectorError('UNREACHABLE', (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message);
} finally { clearTimeout(timer); }
if (res.status === 401 && !retried && !ctx.secrets.token) { await auth(true); return call<T>(method, path, body, headers, true); }
if (res.status === 204) return undefined as T;
const json = await res.json().catch(() => null);
if (res.ok) return json as T;
const detail = detailOf(json);
if (res.status === 401 || res.status === 403) throw new ConnectorError('AUTH_FAILED', `HTTP ${res.status}`);
if (res.status === 404) throw new ConnectorError('NOT_FOUND', detail);
if (res.status === 409) throw new ConnectorError('CONFLICT', detail);
if (res.status === 400 || res.status === 422) throw new ConnectorError('INVALID_INPUT', detail);
if (res.status === 429) throw new ConnectorError('RATE_LIMITED');
throw new ConnectorError('UPSTREAM_ERROR', `HTTP ${res.status}`);
}
let programNames: Promise<Map<number, string>> | null = null;
const programs = () => (programNames ??= call<RawProgram[]>('GET', '/programs/?limit=1000').then((p) => new Map((Array.isArray(p) ? p : []).map((x) => [x.id, x.name]))));
const normalize = async (l: RawLicense) => mapLicense(l, await programs(), 'UTC'); // Lizenzsysteme mit Verwaltungs-API liefern UTC (utc_timestamps)
const activation = (a: RawActivation): LicenseActivation => ({
id: Number(a.id), instanceId: a.instance_id ?? null, hardwareIdMasked: mask(a.hardware_id), environment: a.environment ?? null, lastSeenIp: a.last_seen_ip ?? null,
productVersion: a.product_version ?? null, activatedAt: a.activated_at, lastSeenAt: a.last_seen_at, active: a.is_active !== false,
});
const lid = (id: string | number) => encodeURIComponent(String(id));
return {
normalize,
async get(id: string | number): Promise<LicenseDetail> {
const raw = await call<RawLicense>('GET', `/licenses/${lid(id)}`);
if (!raw || typeof raw.id !== 'number') throw new ConnectorError('INVALID_RESPONSE');
const acts = await call<RawActivation[]>('GET', `/licenses/${lid(id)}/activations`).catch(() => raw.activations ?? []);
return {
resource: await normalize(raw), raw: { ...raw, license_key: '' }, // Schlüssel nie mitgeben (Abruf nur über reveal)
activations: (Array.isArray(acts) ? acts : []).filter((a) => a.is_active !== false).map(activation),
entitlement: { plan: raw.plan_key ?? null, modules: raw.modules ?? [], customerLimit: raw.customer_limit ?? null, version: raw.entitlement_version ?? 0 },
limits: { maxActivations: raw.max_activations ?? null, activationLimit: raw.activation_limit ?? null, userLimit: raw.user_limit ?? null, graceDays: raw.grace_days ?? null, effectiveGraceDays: raw.effective_grace_days ?? null },
origin: { source: raw.source ?? null, orderRef: raw.order_ref ?? null, externalRef: raw.external_ref ?? null, customerName: raw.customer_name ?? null, customerEmail: raw.customer_email ?? null, createdAt: raw.created_at ?? null },
};
},
/** Programme und Produkte (inkl. Add-on-Produkte) für die Vergabe. */
async catalog(): Promise<LicensingCatalog> {
const [p, g] = await Promise.all([call<RawProgram[]>('GET', '/programs/?limit=1000'), call<RawGroup[]>('GET', '/products/groups')]);
const products: CatalogProduct[] = [];
for (const grp of Array.isArray(g) ? g : []) for (const x of grp.products ?? []) {
products.push({ id: x.id, name: x.name, groupName: grp.name, programId: grp.program_id, type: x.product_type ?? 'LICENSED', durationType: x.duration_type ?? 'MONTH', price: x.price != null ? String(x.price) : null, currency: x.currency ?? null,
planKey: x.plan_key ?? null, modules: splitModules(x.modules), customerLimit: x.customer_limit ?? null, userLimit: x.user_limit ?? null, active: x.is_active !== false && grp.is_active !== false });
}
return { programs: (Array.isArray(p) ? p : []).map((x) => ({ id: x.id, name: x.name })), products };
},
create: (body: Record<string, unknown>) => call<RawLicense>('POST', '/licenses/', body),
createTrial: (body: Record<string, unknown>) => call<RawLicense>('POST', '/licenses/trials', body),
update: (id: string | number, body: Record<string, unknown>) => call<RawLicense>('PUT', `/licenses/${lid(id)}`, body),
entitlement: (id: string | number, body: Record<string, unknown>) => call<RawLicense>('POST', `/licenses/${lid(id)}/entitlement`, body),
lifecycle: (id: string | number, action: 'suspend' | 'unsuspend' | 'extend' | 'revoke', body: Record<string, unknown>, idempotencyKey: string) =>
call<{ changed: boolean; license: RawLicense }>('POST', `/licenses/${lid(id)}/${action}`, body, { 'Idempotency-Key': idempotencyKey }),
deleteActivation: (id: string | number, activationId: number) => call<void>('DELETE', `/licenses/${lid(id)}/activations/${encodeURIComponent(String(activationId))}`),
};
}
export type LicensingAdmin = ReturnType<typeof createLicensingAdmin>;