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

@ -14,6 +14,7 @@ import { backupModule } from './backup/index.js';
import { mailModule } from './mail/index.js';
import { discordModule } from './discord/index.js';
import { licenseModule } from './license/index.js';
import { licensingModule } from './licensing/index.js';
/** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule];
export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule, licensingModule];

View file

@ -0,0 +1,266 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { ConnectorError } from '@kc/connector-sdk';
import { createLicensingAdmin, loadInstance, upsertResource, type LicensingAdmin } from '@kc/connectors';
import { one, query, run } from '../../core/db.js';
import { rl } from '../../core/config.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
/**
* Lizenzverwaltung: Übersicht, Vergabe und Pflege von Kunden-Lizenzen im eigenen Lizenzsystem (licensing.flessinglabs.com).
* Datenbasis der Übersicht ist der Abgleich (resources, type = 'license'); Detail und Änderungen gehen live ans Lizenzsystem.
* Vergabe "mit Vertrag" läuft über den normalen Bestellweg (POST /orders), hier nur die Vergabe ohne Berechnung.
* Nicht zu verwechseln mit dem Modul "license" (eigene Lizenz dieser Installation).
*/
const json = <T>(v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T));
const LIC_SQL = `SELECT r.*, i.connector_key, i.health, i.enabled AS inst_enabled, o.name AS org_name, o.customer_number,
(SELECT c.id FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_id,
(SELECT c.number FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_number
FROM resources r JOIN connector_instances i ON i.id = r.instance_id LEFT JOIN organizations o ON o.id = r.org_id
WHERE r.type = 'license' AND i.connector_key = 'licensing'`;
function listView(r: any) {
const d = json<{ details?: Record<string, any>; limits?: Record<string, any> }>(r.data_json, {});
const x = d.details ?? {};
return {
id: r.id, name: r.name, state: r.state, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
orgId: r.org_id, orgName: r.org_name ?? null, customerNumber: r.customer_number ?? null, contractId: r.contract_id ?? null, contractNumber: r.contract_number ?? null,
program: x.program ?? null, programId: x.programId ?? null, product: x.product ?? null, edition: x.edition ?? null, keyMasked: x.licenseKeyMasked ?? null,
activationsUsed: x.activationsUsed ?? 0, activationLimit: x.activationLimit ?? null, trial: !!x.trial, trialPending: !!x.trialPending, addon: !!x.addon,
parentLicenseId: x.parentLicenseId ?? null, revoked: !!x.revoked, externalRef: r.external_ref,
};
}
const access = (a: AuthContext, r: any) => can(a.principal, 'licenses.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.read', 'licenses.read'));
/** Kunden-Selbstbedienung: Inhaber/Admin der Organisation dürfen eigene Geräte freigeben (Aktivierung zurücksetzen). */
const canResetActivation = (a: AuthContext, r: any) => can(a.principal, 'licenses.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.manage', 'licenses.write'));
async function loadLicense(id: string) { return one(`${LIC_SQL} AND r.id = ?`, [id]); }
async function adminFor(instanceId: string, correlationId: string): Promise<LicensingAdmin> {
const { inst, ctx } = await loadInstance(instanceId, correlationId);
if (!inst.enabled) throw new AppError(409, 'CONNECTOR_DISABLED', 'Die Verbindung zum Lizenzsystem ist deaktiviert.');
return createLicensingAdmin(ctx);
}
/** Fehler des Lizenzsystems verständlich weitergeben (abgelehnte Eingaben mit dessen Begründung). */
function providerError(e: unknown): never {
if (e instanceof ConnectorError) {
if (e.code === 'INVALID_INPUT' || e.code === 'CONFLICT') throw new AppError(e.code === 'CONFLICT' ? 409 : 400, 'LICENSING_REJECTED', `Das Lizenzsystem lehnt das ab: ${e.detail ?? e.userMessage}`);
if (e.code === 'NOT_FOUND') throw new AppError(404, 'LICENSING_NOT_FOUND', 'Die Lizenz wurde im Lizenzsystem nicht gefunden.');
throw new AppError(502, 'CONNECTOR_ERROR', `Lizenzsystem: ${e.userMessage}`);
}
throw e;
}
/** Nach einer Änderung: lokalen Stand sofort aktualisieren (Übersicht) und vollständigen Abgleich anstoßen. */
async function refresh(admin: LicensingAdmin, r: any, raw: Parameters<LicensingAdmin['normalize']>[0] | undefined, correlationId: string) {
if (raw) await upsertResource(r.instance_id, await admin.normalize(raw)).catch(() => undefined);
await enqueue('connector.sync', { instanceId: r.instance_id }, { idempotencyKey: `sync:${r.instance_id}:licensing:${Math.floor(Date.now() / 15000)}`, correlationId });
}
const reasonSchema = z.string().trim().max(255).optional();
export const licensingModule: KcModule = {
name: 'licensing',
permissions: {
staff: { support: ['licenses.read'], accounting: ['licenses.read'], admin: ['licenses.read', 'licenses.write'], superadmin: ['licenses.read', 'licenses.write'] },
org: { owner: ['licenses.read', 'licenses.manage'], admin: ['licenses.read', 'licenses.manage'], member: ['licenses.read'] },
},
register(app: FastifyInstance) {
// ---- Übersicht -------------------------------------------------------------------------------------------
app.get('/licenses', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), state: z.enum(['active', 'suspended', 'expired']).optional(), expiring: z.enum(['1']).optional(), unassigned: z.enum(['1']).optional(), q: z.string().trim().max(100).optional() }).parse(req.query);
const staff = can(a.principal, 'licenses.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && orgs.length === 0) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.state) { where.push('r.state = ?'); params.push(q.state); }
if (q.expiring) where.push("r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)");
if (q.unassigned && staff) where.push('r.org_id IS NULL');
if (q.q) { where.push('(r.name LIKE ? OR o.name LIKE ? OR o.customer_number = ? OR r.external_ref = ?)'); params.push(`%${q.q}%`, `%${q.q}%`, q.q, q.q); }
const rows = await query(`${LIC_SQL}${where.length ? ' AND ' + where.join(' AND ') : ''} ORDER BY (r.valid_until IS NULL), r.valid_until, r.name LIMIT 1000`, params);
return rows.map(listView);
});
app.get('/admin/licenses/summary', async (req) => {
requirePermission(req, 'licenses.read');
const s = await one(`SELECT COUNT(*) AS total, SUM(r.state = 'active') AS active, SUM(r.state = 'suspended') AS suspended, SUM(r.state = 'expired') AS expired,
SUM(r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) AS expiring, SUM(r.org_id IS NULL) AS unassigned
FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.type = 'license' AND i.connector_key = 'licensing' AND r.missing_since IS NULL`);
const n = (v: unknown) => Number(v ?? 0);
return { total: n(s?.total), active: n(s?.active), suspended: n(s?.suspended), expired: n(s?.expired), expiring: n(s?.expiring), unassigned: n(s?.unassigned) };
});
// ---- Detail (live aus dem Lizenzsystem, Rückfall auf den letzten Abgleich) --------------------------------
app.get('/licenses/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadLicense(id);
if (!r || !access(a, r)) throw notFound();
const staff = can(a.principal, 'licenses.read'); const write = can(a.principal, 'licenses.write');
const base = listView(r);
let live: Awaited<ReturnType<LicensingAdmin['get']>> | null = null; let liveError: string | null = null;
try { live = await (await adminFor(r.instance_id, req.correlationId)).get(r.external_ref); }
catch (e) { liveError = e instanceof ConnectorError ? e.userMessage : e instanceof AppError ? e.message : 'Das Lizenzsystem ist nicht erreichbar.'; }
if (live) await upsertResource(r.instance_id, live.resource).catch(() => undefined);
// Add-ons dieser Lizenz und (bei Add-ons) die Basislizenz, soweit im Kundencenter bekannt
const addons = (await query(`${LIC_SQL} AND r.instance_id = ? AND JSON_VALUE(r.data_json, '$.details.parentLicenseId') = ?`, [r.instance_id, r.external_ref]))
.filter((x) => access(a, x)).map(listView);
const parentRef = live?.raw.parent_license_id ?? base.parentLicenseId;
const parent = parentRef ? await one(`${LIC_SQL} AND r.instance_id = ? AND r.external_ref = ?`, [r.instance_id, String(parentRef)]) : null;
const history = staff ? (await query("SELECT action, actor_id, result, ts AS created_at FROM audit_events WHERE resource_type = 'resource' AND resource_id = ? ORDER BY id DESC LIMIT 30", [id])).map((h) => ({ action: h.action, result: h.result, at: h.created_at, actorId: h.actor_id })) : [];
const actorNames = new Map((history.length ? await query(`SELECT id, name FROM users WHERE id IN (${[...new Set(history.map((h) => h.actorId).filter(Boolean))].map(() => '?').join(',') || 'NULL'})`, [...new Set(history.map((h) => h.actorId).filter(Boolean))]) : []).map((u) => [u.id, u.name]));
const caps = json<string[]>((await one('SELECT capabilities_json FROM connector_instances WHERE id = ?', [r.instance_id]))?.capabilities_json, []);
return {
...(live ? listView({ ...r, name: live.resource.name, state: live.resource.state, valid_from: live.resource.validFrom, valid_until: live.resource.validUntil, data_json: { details: live.resource.details } }) : base),
live: !!live, liveError,
activations: live?.activations ?? [], entitlement: live?.entitlement ?? null, limits: live?.limits ?? null,
origin: staff ? (live?.origin ?? null) : null, providerStatus: live?.raw.status ?? null, revokeReason: staff ? (live?.raw.revoke_reason ?? null) : null,
durationType: live?.raw.duration_type ?? null, productId: live?.raw.product_id ?? null, lastCheckAt: live?.raw.last_check_at ?? null,
addons, parent: parent && access(a, parent) ? listView(parent) : null,
history: history.map((h) => ({ ...h, actor: h.actorId ? (actorNames.get(h.actorId) ?? null) : 'System' })),
can: { reveal: caps.includes('secret.reveal') && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'))), // gleiche Regel wie /resources/:id/reveal
resetActivation: !!live && canResetActivation(a, r), manage: write && !!live },
};
});
// ---- Aktivierung (Gerät) freigeben: Personal oder Kunde (Inhaber/Admin) für die eigene Lizenz ------------
app.delete('/licenses/:id/activations/:activationId', { config: rl(10, '10 minutes') }, async (req) => {
const a = requireAuth(req);
const { id, activationId } = z.object({ id: z.string().uuid(), activationId: z.coerce.number().int().positive() }).parse(req.params);
const r = await loadLicense(id);
if (!r || !access(a, r)) throw notFound();
if (!canResetActivation(a, r)) throw forbidden('Geräte dieser Lizenz können nur vom Inhaber oder Support freigegeben werden', 'ACTIVATION_RESET_FORBIDDEN');
const admin = await adminFor(r.instance_id, req.correlationId);
try { await admin.deleteActivation(r.external_ref, activationId); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.activation.reset', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { activationId } });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId);
return { ok: true };
});
// ---- Personal: Limits, Produkt (Upgrade/Testumwandlung), Entitlement, Lebenszyklus --------------------------
app.patch('/admin/licenses/:id', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({
maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).nullable().optional(),
customerLimit: z.number().int().min(0).max(1000000).nullable().optional(), graceDays: z.number().int().min(0).max(365).nullable().optional(),
productId: z.number().int().positive().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), expiresAt: z.iso.datetime().nullable().optional(),
}).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
const body: Record<string, unknown> = {};
if (b.maxActivations !== undefined) body.max_activations = b.maxActivations;
if (b.userLimit !== undefined) body.user_limit = b.userLimit;
if (b.customerLimit !== undefined) body.customer_limit = b.customerLimit;
if (b.graceDays !== undefined) body.grace_days = b.graceDays;
if (b.productId !== undefined) body.product_id = b.productId;
if (b.durationType !== undefined) body.duration_type = b.durationType;
if (b.expiresAt !== undefined) body.expires_at = b.expiresAt;
if (!Object.keys(body).length) throw badRequest('Keine Änderung angegeben');
const admin = await adminFor(r.instance_id, req.correlationId);
let raw; try { raw = await admin.update(r.external_ref, body); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw ?? raw, req.correlationId);
return { ok: true };
});
app.post('/admin/licenses/:id/entitlement', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ fromProduct: z.boolean().default(false), planKey: z.string().trim().max(50).optional(), modules: z.array(z.string().trim().min(1).max(100)).max(200).optional(),
customerLimit: z.number().int().min(0).max(1000000).optional(), clearCustomerLimit: z.boolean().default(false), reason: reasonSchema }).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
const admin = await adminFor(r.instance_id, req.correlationId);
try { await admin.entitlement(r.external_ref, { from_product: b.fromProduct, plan_key: b.planKey, modules: b.modules, customer_limit: b.customerLimit, clear_customer_limit: b.clearCustomerLimit, reason: b.reason ?? `Kundencenter (${a.user.name})` }); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.entitlement', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b });
await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId);
return { ok: true };
});
app.post('/admin/licenses/:id/lifecycle', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend', 'revoke']), until: z.iso.datetime().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), count: z.number().int().min(1).max(120).optional(), reason: reasonSchema }).parse(req.body);
if (b.action === 'extend' && !b.until && !b.durationType) throw badRequest('Bitte ein Datum oder eine Laufzeit angeben');
if (b.action === 'revoke' && !b.reason) throw badRequest('Bitte einen Grund für den Widerruf angeben');
const r = await loadLicense(id); if (!r) throw notFound();
// Idempotenz pro Bestätigungsdialog (Header), sonst pro Minute: ein Doppelklick verlängert nicht zweimal
const hdr = req.headers['idempotency-key'];
const key = typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : `${id}:${b.action}:${b.until ?? ''}:${b.durationType ?? ''}:${b.count ?? ''}:${Math.floor(Date.now() / 60000)}`;
const body: Record<string, unknown> = { reason: b.reason ?? `Kundencenter (${a.user.name})` };
if (b.action === 'extend') Object.assign(body, b.until ? { until: b.until } : { duration_type: b.durationType, count: b.count ?? 1 });
const admin = await adminFor(r.instance_id, req.correlationId);
let out; try { out = await admin.lifecycle(r.external_ref, b.action, body, `kc:${key}`); } catch (e) { providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `license.${b.action}`, resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, changed: out?.changed } });
await refresh(admin, r, out?.license, req.correlationId);
return { ok: true, changed: !!out?.changed };
});
app.patch('/admin/licenses/:id/assign', async (req) => {
const a = requirePermission(req, 'licenses.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ orgId: z.string().uuid().nullable() }).parse(req.body);
const r = await loadLicense(id); if (!r) throw notFound();
if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden');
await run('UPDATE resources SET org_id = ? WHERE id = ?', [b.orgId, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id }, after: { orgId: b.orgId } });
return { ok: true };
});
// ---- Vergabe ohne Berechnung (Kulanz, Test, intern). Mit Vertrag: normaler Bestellweg (POST /orders). ---------
app.get('/admin/licenses/catalog', async (req) => {
requirePermission(req, 'licenses.write');
const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1");
if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet (Einstellungen → Verbindungen).');
let catalog; try { catalog = await (await adminFor(inst.id, req.correlationId)).catalog(); } catch (e) { providerError(e); }
// Produkte des Kundencenters, die eine Lizenz bereitstellen (für "mit Vertrag")
const shop = (await query(`SELECT p.id, v.name, v.recurring_cents, v.setup_cents, v.price_basis, v.billing_interval, v.term_months, v.provisioning_json
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.status = 'active' AND p.connector_instance_id = ? ORDER BY v.name`, [inst.id]))
.map((p) => ({ id: p.id, name: p.name, recurringCents: p.recurring_cents, setupCents: p.setup_cents, priceBasis: p.price_basis, interval: p.billing_interval, termMonths: p.term_months, provisioning: json(p.provisioning_json, {}) }));
return { instanceId: inst.id, ...catalog, shopProducts: shop };
});
app.post('/admin/licenses', { config: rl(20, '1 minute') }, async (req) => {
const a = requirePermission(req, 'licenses.write');
const b = z.object({
orgId: z.string().uuid(), kind: z.enum(['license', 'trial', 'addon']), programId: z.number().int().positive(), productId: z.number().int().positive(),
parentId: z.string().uuid().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).default('YEAR'), expiresAt: z.iso.datetime().optional(),
maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).optional(), customerLimit: z.number().int().min(0).max(1000000).optional(),
keyPrefix: z.enum(['PREMIUM', 'TRIAL', 'LIFETIME']).optional(), note: z.string().trim().max(50).optional(),
}).parse(req.body);
const org = await one("SELECT o.id, o.name, o.customer_number, o.status FROM organizations o WHERE o.id = ?", [b.orgId]);
if (!org) throw badRequest('Kunde nicht gefunden');
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann keine Lizenz vergeben werden', 'ORG_INACTIVE');
const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY (m.role = 'owner') DESC, m.created_at LIMIT 1", [b.orgId]);
const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1");
if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet.');
let parentRef: number | undefined;
if (b.kind === 'addon') {
if (!b.parentId) throw badRequest('Für ein Add-on bitte die Basislizenz wählen');
const p = await loadLicense(b.parentId);
if (!p || p.org_id !== b.orgId || p.instance_id !== inst.id) throw badRequest('Die Basislizenz gehört nicht zu diesem Kunden');
parentRef = Number(p.external_ref);
}
const ref = `kc-${randomUUID()}`; // Herkunft: verhindert Doppelanlage bei Wiederholung (source + external_ref eindeutig)
const customer = { source: 'kundencenter', customer_name: org.name, customer_email: owner?.email ?? null, customer_contact: owner?.name ?? null, customer_reference: org.customer_number, order_ref: b.note ? b.note.slice(0, 50) : 'ohne Berechnung', external_ref: ref };
const admin = await adminFor(inst.id, req.correlationId);
let raw;
try {
if (b.kind === 'trial') {
if (!owner?.email) throw badRequest('Für einen Test braucht der Kunde eine E-Mail-Adresse (Ansprechpartner).');
raw = await admin.createTrial({ program_id: b.programId, product_id: b.productId, max_activations: b.maxActivations, key_prefix: b.keyPrefix, ...customer });
} else {
raw = await admin.create({
program_id: b.programId, product_id: b.productId, duration_type: b.durationType, is_active: true, ...(b.expiresAt ? { expires_at: b.expiresAt } : {}),
max_activations: b.maxActivations, user_limit: b.userLimit, customer_limit: b.kind === 'addon' ? undefined : b.customerLimit, key_prefix: b.keyPrefix,
parent_license_id: parentRef, ...customer,
});
}
} catch (e) { if (e instanceof AppError) throw e; providerError(e); }
if (!raw || typeof raw.id !== 'number') throw new AppError(502, 'CONNECTOR_ERROR', 'Unerwartete Antwort des Lizenzsystems');
const resourceId = await upsertResource(inst.id, await admin.normalize(raw));
await run("UPDATE resources SET org_id = ?, customer_actions = COALESCE(customer_actions, '[]') WHERE id = ?", [b.orgId, resourceId]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'license.issue', resourceType: 'resource', resourceId, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req),
after: { kind: b.kind, programId: b.programId, productId: b.productId, durationType: b.kind === 'trial' ? 'TRIAL' : b.durationType, expiresAt: b.expiresAt, maxActivations: b.maxActivations, licenseId: raw.id, billing: 'none', note: b.note } });
return { id: resourceId };
});
},
};

View file

@ -9,6 +9,7 @@ interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; b
export default function Dashboard() {
const { me, can } = useSession();
const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null);
const [licenses, setLicenses] = useState<{ active: number; expiring: number; unassigned: number } | null>(null);
const [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
useEffect(() => {
if (me?.kind === 'customer') {
@ -18,6 +19,7 @@ export default function Dashboard() {
if (!me || me.kind !== 'staff') return;
if (can('customers.read')) api<unknown[]>('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e)));
if (can('jobs.read')) api<Sys>('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e)));
if (can('licenses.read')) api<{ active: number; expiring: number; unassigned: number }>('GET', '/admin/licenses/summary').then(setLicenses).catch(() => undefined);
}, [me, can]);
if (!me) return null;
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
@ -37,6 +39,7 @@ export default function Dashboard() {
</>) : (<>
<div className="grid">
{customers !== null && <div className="card"><div className="stat">{customers}</div><p className="muted">Kunden</p><Link href="/admin/kunden">Kunden verwalten</Link></div>}
{licenses && <div className="card"><div className="stat">{licenses.active}</div><p className="muted">Aktive Lizenzen</p>{licenses.expiring > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.expiring} laufen in 30 Tagen ab</span></p>}{licenses.unassigned > 0 && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{licenses.unassigned} ohne Kunde</span></p>}<Link href="/lizenzen">Lizenzen verwalten</Link></div>}
{sys && <div className="card"><div className="stat">{sys.jobs.scheduled ?? 0}</div><p className="muted">Wartende Aufträge</p></div>}
{sys?.backup && <div className="card"><h3>Backup</h3><p className="small"><Link href="/einstellungen/backup">Details und Einstellungen</Link></p>
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>

View file

@ -21,7 +21,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
);
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); }
const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
const produkte = [(me.kind === 'customer' || can('licenses.read')) && ['/lizenzen', me.kind === 'customer' ? 'Meine Lizenzen' : 'Lizenzen'], (me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
const nav = (
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">

View file

@ -0,0 +1,170 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, ResState, fmt } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { LicKind, type Lic } from '@/components/Licenses';
interface Activation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string }
interface Detail extends Lic {
live: boolean; liveError: string | null; activations: Activation[];
entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number } | null;
limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null } | null;
origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null } | null;
providerStatus: string | null; revokeReason: string | null; durationType: string | null; productId: number | null; programId: number | null; lastCheckAt: string | null;
addons: Lic[]; parent: Lic | null; history: { action: string; result: string; at: string; actor: string | null }[];
can: { reveal: boolean; resetActivation: boolean; manage: boolean };
}
interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: string; durationType: string; active: boolean }
const DURATION: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'unbefristet', TRIAL: 'Test' };
const HISTORY: Record<string, string> = {
'license.issue': 'Lizenz vergeben', 'license.update': 'Lizenz geändert', 'license.entitlement': 'Funktionsumfang geändert', 'license.suspend': 'Gesperrt', 'license.unsuspend': 'Entsperrt',
'license.extend': 'Verlängert', 'license.revoke': 'Widerrufen', 'license.activation.reset': 'Gerät freigegeben', 'resource.reveal': 'Schlüssel angezeigt', 'resource.update': 'Zuordnung geändert',
'resource.suspend': 'Gesperrt (Vertrag)', 'resource.unsuspend': 'Entsperrt (Vertrag)', 'resource.extend': 'Verlängert (Vertrag)',
};
const Dd = ({ label, children }: { label: string; children: ReactNode }) => <div><dt className="muted small">{label}</dt><dd style={{ margin: 0 }}>{children}</dd></div>;
const toLocalInput = (iso: string | null) => (iso ? new Date(new Date(iso).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 10) : '');
export default function Lizenz() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('licenses.read');
const [d, setD] = useState<Detail | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [busy, setBusy] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; text: string; run: () => Promise<unknown> }>(null);
const [products, setProducts] = useState<CatalogProduct[] | null>(null);
const load = useCallback(() => api<Detail>('GET', `/licenses/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (d?.can.manage && products === null) api<{ products: CatalogProduct[] }>('GET', '/admin/licenses/catalog').then((c) => setProducts(c.products)).catch(() => setProducts([])); }, [d?.can.manage, products]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function act(fn: () => Promise<unknown>, ok: string) {
setBusy(true); setMsg(null);
try { await fn(); setMsg({ k: 'ok', t: ok }); setConfirm(null); await load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setConfirm(null); } finally { setBusy(false); }
}
const ask = (title: string, text: string, run: () => Promise<unknown>) => setConfirm({ title, text, run });
const lifecycle = (body: Record<string, unknown>) => api('POST', `/admin/licenses/${id}/lifecycle`, body);
const sameKind = (products ?? []).filter((p) => p.programId === d.programId && (p.type === 'ADDON') === d.addon);
const used = d.activations.length; const limit = d.limits?.activationLimit ?? d.activationLimit;
function saveLimits(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const n = (k: string) => { const v = String(f.get(k) ?? '').trim(); return v === '' ? null : Number(v); };
const body: Record<string, unknown> = { maxActivations: n('maxActivations') ?? undefined, userLimit: n('userLimit'), graceDays: n('graceDays') };
if (!d!.addon) body.customerLimit = n('customerLimit');
void act(() => api('PATCH', `/admin/licenses/${id}`, body), 'Limits gespeichert.');
}
function saveEntitlement(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget);
const modules = String(f.get('modules') ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
void act(() => api('POST', `/admin/licenses/${id}/entitlement`, { planKey: String(f.get('plan') ?? '').trim() || undefined, modules, reason: String(f.get('reason') ?? '').trim() || undefined }), 'Funktionsumfang gespeichert (neue Entitlement-Version).');
}
function changeProduct(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const productId = Number(f.get('productId')); const durationType = String(f.get('durationType') ?? '');
const p = sameKind.find((x) => x.id === productId);
ask(d!.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln', `Die Lizenz wird auf „${p?.groupName} – ${p?.name}“ umgestellt${durationType ? ` (Laufzeit: ${DURATION[durationType]}, Ablauf wird neu berechnet)` : ''}. Plan, Module und Kundenlimit werden vom neuen Produkt übernommen.`,
() => api('PATCH', `/admin/licenses/${id}`, { productId, ...(durationType ? { durationType } : {}) }));
}
function extend(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const until = String(f.get('until') ?? ''); const preset = String(f.get('preset') ?? ''); const reason = String(f.get('reason') ?? '').trim() || undefined;
if (until) { const iso = new Date(`${until}T23:59:59`).toISOString(); ask('Verlängern', `Die Lizenz gilt dann bis ${fmt(iso)}.`, () => lifecycle({ action: 'extend', until: iso, reason })); return; }
if (!preset) { setMsg({ k: 'warn', t: 'Bitte ein Datum oder eine Laufzeit wählen.' }); return; }
const [count, durationType] = preset.split(':');
ask('Verlängern', `Die Lizenz wird um ${count} ${DURATION[durationType!]}${Number(count) > 1 ? 'e' : ''} verlängert (ab dem späteren von heute und dem aktuellen Ablauf).`, () => lifecycle({ action: 'extend', durationType, count: Number(count), reason }));
}
return (<>
<p><Link href="/lizenzen">← Alle Lizenzen</Link></p>
<div className="row between"><h1>{d.program ?? d.name}{d.product ? ` · ${d.product}` : ''}</h1><div className="row"><ResState value={d.state} /><LicKind l={d} /></div></div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{!d.live && <Alert kind="warn"><strong>Das Lizenzsystem ist gerade nicht erreichbar</strong> ({d.liveError}). Angezeigt wird der Stand vom {fmt(d.syncedAt)}, Änderungen sind währenddessen nicht möglich.</Alert>}
{d.trial && d.trialPending && <Alert kind="info">Testlizenz: Die Testzeit beginnt erst mit der ersten Aktivierung.</Alert>}
{d.revoked && <Alert kind="err">Diese Lizenz wurde widerrufen{d.revokeReason ? `: ${d.revokeReason}` : ''}. Ein Widerruf ist endgültig.</Alert>}
{d.can.reveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}
<div className="card"><h2>Details</h2>
<dl className="cols" style={{ margin: 0 }}>
{staff && <Dd label="Kunde">{d.orgId ? <Link href={`/admin/kunden/${d.orgId}`}>{d.customerNumber} · {d.orgName}</Link> : 'nicht zugewiesen'}</Dd>}
<Dd label="Programm">{d.program ?? '–'}</Dd>
<Dd label="Produkt">{d.product ?? d.edition ?? '–'}</Dd>
<Dd label="Laufzeit">{d.durationType ? DURATION[d.durationType] ?? d.durationType : '–'}</Dd>
<Dd label="Gültig ab">{d.validFrom ? fmt(d.validFrom) : '–'}</Dd>
<Dd label="Gültig bis">{d.validUntil ? fmt(d.validUntil) : 'unbefristet'}</Dd>
<Dd label="Letzte Prüfung durch das Programm">{d.lastCheckAt ? fmt(d.lastCheckAt) : 'noch nie'}</Dd>
<Dd label="Abrechnung">{d.contractId ? <Link href={`/vertraege/${d.contractId}`}>Vertrag {d.contractNumber}</Link> : 'ohne Berechnung'}</Dd>
{d.parent && <Dd label="Basislizenz"><Link href={`/lizenzen/${d.parent.id}`}>{d.parent.program}{d.parent.product ? ` · ${d.parent.product}` : ''}</Link></Dd>}
{d.entitlement && <Dd label="Plan">{d.entitlement.plan ?? '–'}</Dd>}
{d.entitlement && !d.addon && <Dd label="Kundenlimit">{d.entitlement.customerLimit ?? 'unbegrenzt'}</Dd>}
{staff && d.origin && <Dd label="Herkunft">{d.origin.source ?? '–'}{d.origin.orderRef ? ` · ${d.origin.orderRef}` : ''}</Dd>}
</dl>
{d.entitlement && d.entitlement.modules.length > 0 && <p className="small" style={{ marginTop: 16 }}><span className="muted">Freigeschaltete Module:</span> {d.entitlement.modules.join(', ')}</p>}
</div>
<div className="card"><h2>Geräte <span className="muted small">({used} von {limit ?? '∞'})</span></h2>
{d.activations.length === 0 ? <p className="muted">{d.live ? 'Die Lizenz ist noch auf keinem Gerät aktiviert.' : 'Keine Angaben verfügbar.'}</p> : <div className="tablewrap"><table><thead><tr><th>Gerät</th><th>Umgebung</th><th>Version</th><th>Aktiviert</th><th>Zuletzt gesehen</th>{staff && <th>IP</th>}<th></th></tr></thead><tbody>
{d.activations.map((a) => <tr key={a.id}>
<td className="mono small">{a.instanceId ?? a.hardwareIdMasked ?? `#${a.id}`}</td><td>{a.environment ?? '–'}</td><td>{a.productVersion ?? '–'}</td><td>{fmt(a.activatedAt)}</td><td>{fmt(a.lastSeenAt)}</td>{staff && <td className="mono small">{a.lastSeenIp ?? '–'}</td>}
<td>{d.can.resetActivation && <button className="btn small" disabled={busy} onClick={() => ask('Gerät freigeben', 'Das Gerät wird von der Lizenz gelöst. Das Programm auf diesem Gerät meldet sich danach als nicht lizenziert; der Platz kann auf einem anderen Gerät genutzt werden.', () => api('DELETE', `/licenses/${id}/activations/${a.id}`))}>Freigeben</button>}</td>
</tr>)}</tbody></table></div>}
{!staff && d.can.resetActivation && <p className="small muted" style={{ marginTop: 12 }}>Neues Gerät? Altes Gerät hier freigeben, dann das Programm auf dem neuen Gerät mit demselben Schlüssel aktivieren.</p>}
</div>
{d.can.manage && <>
<div className="card"><h2>Lebenszyklus</h2>
<div className="row" style={{ marginBottom: 16 }}>
{d.state !== 'suspended' && !d.revoked && <button className="btn" disabled={busy} onClick={() => ask('Sperren', 'Das Programm meldet sich danach als nicht lizenziert, bis die Lizenz wieder entsperrt wird.', () => lifecycle({ action: 'suspend' }))}>Sperren</button>}
{d.state === 'suspended' && !d.revoked && <button className="btn" disabled={busy} onClick={() => ask('Entsperren', 'Die Lizenz ist danach wieder nutzbar.', () => lifecycle({ action: 'unsuspend' }))}>Entsperren</button>}
{!d.revoked && <button className="btn" disabled={busy} onClick={() => { const reason = window.prompt('Grund für den Widerruf (Pflicht, wird protokolliert):')?.trim(); if (reason) ask('Endgültig widerrufen', `Der Widerruf ist endgültig und kann nicht rückgängig gemacht werden. Grund: ${reason}`, () => lifecycle({ action: 'revoke', reason })); }}>Widerrufen …</button>}
</div>
{d.durationType !== 'UNLIMITED' && !d.revoked && <form onSubmit={extend}>
<div className="cols">
<Field id="preset" label="Verlängern um"><select id="preset" name="preset" defaultValue=""><option value="">–</option>{!d.trial && <><option value="1:MONTH">1 Monat</option><option value="3:MONTH">3 Monate</option><option value="1:YEAR">1 Jahr</option></>}<option value="1:UNLIMITED" disabled={d.trial}>unbefristet</option></select></Field>
<Field id="until" label="oder gültig bis (Datum)" hint={d.trial ? 'Tests lassen sich nur mit festem Datum verlängern (Kulanz).' : undefined}><input id="until" name="until" type="date" min={toLocalInput(new Date().toISOString())} /></Field>
<Field id="reason" label="Grund (optional, wird protokolliert)"><input id="reason" name="reason" maxLength={255} /></Field>
</div>
<button className="btn primary" type="submit" disabled={busy}>Verlängern</button>
</form>}
</div>
{sameKind.length > 0 && <div className="card"><h2>{d.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln (Upgrade/Downgrade)'}</h2>
{d.trial && /^(TRIAL|PREMIUM|LIFETIME)-/i.test(d.keyMasked ?? '') && <Alert kind="warn">Der Schlüssel trägt ein Editions-Präfix. Programme wie das Familytool erkennen die Edition am Schlüssel – dort bitte stattdessen eine neue Lizenz vergeben.</Alert>}
<form onSubmit={changeProduct}><div className="cols">
<Field id="productId" label="Neues Produkt"><select id="productId" name="productId" required defaultValue={d.productId ?? ''}><option value="" disabled>Produkt wählen …</option>{sameKind.map((p) => <option key={p.id} value={p.id} disabled={p.id === d.productId}>{p.groupName} – {p.name}{p.active ? '' : ' (inaktiv)'}</option>)}</select></Field>
<Field id="durationType" label={d.trial ? 'Laufzeit der Vollversion' : 'Laufzeit (optional neu setzen)'}><select id="durationType" name="durationType" required={d.trial} defaultValue=""><option value="">{d.trial ? 'bitte wählen …' : 'unverändert'}</option>{['MONTH', 'YEAR', 'UNLIMITED'].map((x) => <option key={x} value={x}>{DURATION[x]}</option>)}</select></Field>
</div><button className="btn" type="submit" disabled={busy}>Umstellen …</button></form>
</div>}
<div className="cols">
<div className="card"><h2>Limits</h2>
<form onSubmit={saveLimits}>
<Field id="maxActivations" label="Geräte gleichzeitig"><input id="maxActivations" name="maxActivations" type="number" min={1} max={1000} defaultValue={d.limits?.maxActivations ?? d.limits?.activationLimit ?? 1} /></Field>
<Field id="userLimit" label="Benutzer/Slots (leer = unbegrenzt)"><input id="userLimit" name="userLimit" type="number" min={0} defaultValue={d.limits?.userLimit ?? ''} /></Field>
{!d.addon && <Field id="customerLimit" label="Kundenlimit (leer = unbegrenzt)"><input id="customerLimit" name="customerLimit" type="number" min={0} defaultValue={d.entitlement?.customerLimit ?? ''} /></Field>}
<Field id="graceDays" label={`Offline-Gnadenfrist in Tagen (leer = vom Produkt${d.limits?.effectiveGraceDays != null ? `, derzeit ${d.limits.effectiveGraceDays}` : ''})`}><input id="graceDays" name="graceDays" type="number" min={0} max={365} defaultValue={d.limits?.graceDays ?? ''} /></Field>
<button className="btn" type="submit" disabled={busy}>Limits speichern</button>
</form></div>
<div className="card"><h2>Funktionsumfang <span className="muted small">(Version {d.entitlement?.version ?? 0})</span></h2>
<p className="small muted">Plan und Module sind bei der Vergabe an der Lizenz fixiert. Änderungen am Produkt wirken erst nach „Vom Produkt übernehmen“.</p>
<div className="row" style={{ marginBottom: 12 }}><button className="btn" disabled={busy} onClick={() => ask('Vom Produkt übernehmen', 'Plan, Module und Kundenlimit werden mit den aktuellen Werten des Produkts überschrieben.', () => api('POST', `/admin/licenses/${id}/entitlement`, { fromProduct: true }))}>Vom Produkt übernehmen</button></div>
<form onSubmit={saveEntitlement}>
<Field id="plan" label="Plan"><input id="plan" name="plan" maxLength={50} defaultValue={d.entitlement?.plan ?? ''} /></Field>
<Field id="modules" label="Module (eins pro Zeile oder kommagetrennt)"><textarea id="modules" name="modules" rows={4} defaultValue={(d.entitlement?.modules ?? []).join('\n')} /></Field>
<Field id="ereason" label="Grund (optional)"><input id="ereason" name="reason" maxLength={255} /></Field>
<button className="btn" type="submit" disabled={busy}>Funktionsumfang speichern</button>
</form></div>
</div>
</>}
{(!d.addon && (d.addons.length > 0 || d.can.manage)) && <div className="card"><div className="row between"><h2>Add-ons</h2>{d.can.manage && d.orgId && <Link className="btn small" href={`/lizenzen/neu?org=${d.orgId}&parent=${d.id}`}>Add-on vergeben</Link>}</div>
{d.addons.length === 0 ? <p className="muted">Keine Add-ons.</p> : <ul>{d.addons.map((x) => <li key={x.id}><Link href={`/lizenzen/${x.id}`}>{x.product ?? x.name}</Link> <ResState value={x.state} /> <span className="small muted">bis {x.validUntil ? fmt(x.validUntil) : 'unbefristet'}</span></li>)}</ul>}
</div>}
{staff && d.history.length > 0 && <div className="card"><h2>Verlauf</h2><div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Vorgang</th><th>Durch</th><th>Ergebnis</th></tr></thead><tbody>
{d.history.map((h, i) => <tr key={i}><td>{fmt(h.at)}</td><td>{HISTORY[h.action] ?? h.action}</td><td>{h.actor ?? '–'}</td><td>{h.result === 'success' ? 'ok' : h.result}</td></tr>)}</tbody></table></div></div>}
{confirm && <div role="dialog" aria-modal="true" aria-labelledby="cf-t" style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.45)', display: 'grid', placeItems: 'center', padding: 16, zIndex: 50 }}>
<div className="card" style={{ maxWidth: 480, width: '100%' }}><h2 id="cf-t">{confirm.title}</h2><p>{confirm.text}</p>
<div className="row"><button className="btn primary" disabled={busy} onClick={() => void act(confirm.run, `${confirm.title}: erledigt.`)}>{busy ? 'Bitte warten …' : 'Bestätigen'}</button><button className="btn" disabled={busy} onClick={() => setConfirm(null)}>Abbrechen</button></div>
</div></div>}
</>);
}

View file

@ -0,0 +1,107 @@
'use client';
import { Suspense, useEffect, useMemo, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert, Field, eur, INTERVAL } from '@/components/ui';
import type { Lic } from '@/components/Licenses';
interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: string; durationType: string; price: string | null; currency: string | null; planKey: string | null; modules: string[]; customerLimit: number | null; active: boolean }
interface ShopProduct { id: string; name: string; recurringCents: number; setupCents: number; priceBasis: string; interval: string; termMonths: number; provisioning: { programId?: number; productId?: number } }
interface Catalog { programs: { id: number; name: string }[]; products: CatalogProduct[]; shopProducts: ShopProduct[] }
interface Cust { id: string; name: string; customerNumber: string; status: string }
const DURATION: Record<string, string> = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'unbefristet' };
/** Lizenz an einen Kunden vergeben: mit Vertrag (normaler Bestellweg, Preis und Verlängerung aus dem Produkt) oder ohne Berechnung (direkt im Lizenzsystem). */
function Vergeben() {
const sp = useSearchParams(); const router = useRouter();
const [cat, setCat] = useState<Catalog | null>(null); const [custs, setCusts] = useState<Cust[]>([]); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
const [done, setDone] = useState<{ number: string } | null>(null);
const [org, setOrg] = useState(sp.get('org') ?? ''); const parentParam = sp.get('parent') ?? '';
const [mode, setMode] = useState<'contract' | 'free'>(parentParam ? 'free' : 'contract');
const [kind, setKind] = useState<'license' | 'trial' | 'addon'>(parentParam ? 'addon' : 'license');
const [program, setProgram] = useState(0); const [product, setProduct] = useState(0); const [parent, setParent] = useState(parentParam);
const [orgLicenses, setOrgLicenses] = useState<Lic[]>([]);
useEffect(() => {
api<Catalog>('GET', '/admin/licenses/catalog').then(setCat).catch((e) => setErr(errMsg(e)));
api<Cust[]>('GET', '/admin/customers').then((l) => setCusts(l.filter((c) => c.status === 'active'))).catch(() => undefined);
}, []);
useEffect(() => { if (org) api<Lic[]>('GET', `/licenses?org=${org}`).then(setOrgLicenses).catch(() => setOrgLicenses([])); else setOrgLicenses([]); }, [org]);
// Add-on aus der Detailseite: Programm der Basislizenz vorwählen
const parentLic = orgLicenses.find((l) => l.id === parent);
useEffect(() => { if (kind === 'addon' && parentLic && cat) { const p = cat.programs.find((x) => x.name === parentLic.program); if (p) setProgram(p.id); } }, [kind, parentLic, cat]);
const products = useMemo(() => (cat?.products ?? []).filter((p) => p.programId === program && (kind === 'addon' ? p.type === 'ADDON' : p.type !== 'ADDON')), [cat, program, kind]);
const sel = products.find((p) => p.id === product);
const bases = orgLicenses.filter((l) => !l.addon && !l.revoked);
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!org) { setErr('Bitte einen Kunden wählen.'); return; }
const f = new FormData(e.currentTarget); const v = (k: string) => String(f.get(k) ?? '').trim(); const n = (k: string) => (v(k) === '' ? undefined : Number(v(k)));
setBusy(true); setErr('');
try {
if (mode === 'contract') {
const r = await api<{ id: string; number: string }>('POST', '/orders', { orgId: org, note: v('note') || undefined, items: [{ productId: v('shopProduct'), quantity: 1, discountBp: Math.round((n('discount') ?? 0) * 100) }] });
setDone({ number: r.number });
} else {
const until = v('expiresAt');
const r = await api<{ id: string }>('POST', '/admin/licenses', {
orgId: org, kind, programId: program, productId: product, parentId: kind === 'addon' ? parent : undefined,
durationType: kind === 'trial' ? undefined : v('durationType') || sel?.durationType || 'YEAR', expiresAt: until && kind !== 'trial' ? new Date(`${until}T23:59:59`).toISOString() : undefined,
maxActivations: n('maxActivations'), customerLimit: kind === 'license' ? n('customerLimit') : undefined, keyPrefix: v('keyPrefix') || undefined, note: v('note') || undefined,
});
router.push(`/lizenzen/${r.id}`);
}
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (done) return (<>
<h1>Lizenz vergeben</h1>
<Alert kind="ok">Bestellung {done.number} wurde angelegt und wird bereitgestellt. Die Lizenz erscheint in wenigen Sekunden in der Übersicht und beim Kunden, der Vertrag unter „Verträge“.</Alert>
<div className="row"><Link className="btn primary" href="/lizenzen">Zur Übersicht</Link><Link className="btn" href="/bestellungen">Bestellungen ansehen</Link><button className="btn" onClick={() => setDone(null)}>Weitere Lizenz vergeben</button></div>
</>);
return (<>
<p><Link href="/lizenzen">← Alle Lizenzen</Link></p>
<h1>Lizenz vergeben</h1>
{err && <Alert kind="err">{err}</Alert>}
{!cat ? <p className="muted" role="status">{err ? '' : 'Lizenzsystem wird abgefragt …'}</p> : <form onSubmit={submit}>
<div className="card"><h2>Kunde</h2>
<Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setParent(''); }} required><option value="">Kunde wählen …</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>
<fieldset style={{ border: 0, padding: 0, margin: 0 }}><legend className="small muted">Abrechnung</legend>
<label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={mode === 'contract'} onChange={() => setMode('contract')} disabled={kind === 'addon' && !!parentParam} /> Mit Vertrag – Preis, Laufzeit und Verlängerung aus dem Produkt, Rechnung wie bei einer Bestellung</label>
<label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={mode === 'free'} onChange={() => setMode('free')} /> Ohne Berechnung – Test, Kulanz, intern (direkt im Lizenzsystem)</label>
</fieldset>
</div>
{mode === 'contract' ? <div className="card"><h2>Produkt</h2>
{cat.shopProducts.length === 0 ? <Alert kind="warn">Es gibt noch kein aktives Produkt, das eine Lizenz bereitstellt. Unter Produkte → „Aus Anbieter übernehmen“ anlegen.</Alert> : <>
<Field id="shopProduct" label="Produkt"><select id="shopProduct" name="shopProduct" required defaultValue=""><option value="" disabled>Produkt wählen …</option>{cat.shopProducts.map((p) => <option key={p.id} value={p.id}>{p.name} – {eur(p.recurringCents)} {INTERVAL[p.interval] ?? p.interval}{p.setupCents ? ` + ${eur(p.setupCents)} einmalig` : ''}</option>)}</select></Field>
<div className="cols"><Field id="discount" label="Rabatt in % (optional)"><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" /></Field>
<Field id="note" label="Notiz zur Bestellung (optional)"><input id="note" name="note" maxLength={500} /></Field></div>
<p className="small muted">Die Bestellung wird sofort freigegeben; Vertrag und Lizenz entstehen automatisch. Limits und Geräteanzahl lassen sich danach auf der Lizenzseite anpassen.</p>
</>}
</div> : <div className="card"><h2>Lizenz</h2>
<fieldset style={{ border: 0, padding: 0, margin: '0 0 16px' }}><legend className="small muted">Art</legend><div className="row">
{([['license', 'Lizenz'], ['trial', 'Test (einmal je Kunde und Programm)'], ['addon', 'Add-on zu einer Basislizenz']] as const).map(([k, l]) => <label key={k} className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={kind === k} onChange={() => { setKind(k); setProduct(0); }} /> {l}</label>)}
</div></fieldset>
{kind === 'addon' && <Field id="parent" label="Basislizenz des Kunden"><select id="parent" value={parent} onChange={(e) => setParent(e.target.value)} required><option value="">{org ? (bases.length ? 'Basislizenz wählen …' : 'Dieser Kunde hat keine Basislizenz') : 'Erst Kunde wählen'}</option>{bases.map((l) => <option key={l.id} value={l.id}>{l.program}{l.product ? ` · ${l.product}` : ''} ({l.keyMasked})</option>)}</select></Field>}
<div className="cols">
<Field id="program" label="Programm"><select id="program" value={program || ''} onChange={(e) => { setProgram(Number(e.target.value)); setProduct(0); }} required disabled={kind === 'addon' && !!parentLic}><option value="">Programm wählen …</option>{cat.programs.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></Field>
<Field id="product" label={kind === 'addon' ? 'Add-on-Produkt' : 'Produkt'}><select id="product" value={product || ''} onChange={(e) => setProduct(Number(e.target.value))} required><option value="">{program ? (products.length ? 'Produkt wählen …' : 'Keine passenden Produkte') : 'Erst Programm wählen'}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.groupName} – {p.name}{p.active ? '' : ' (inaktiv)'}</option>)}</select></Field>
</div>
{sel && <p className="small muted">Plan: {sel.planKey ?? '–'} · Module: {sel.modules.length ? sel.modules.join(', ') : '–'}{sel.type !== 'ADDON' ? ` · Kundenlimit: ${sel.customerLimit ?? 'unbegrenzt'}` : ''} · Laufzeit laut Produkt: {DURATION[sel.durationType] ?? sel.durationType}</p>}
{kind === 'trial' ? <p className="small muted">Ein Test läuft 30 Tage ab der ersten Aktivierung, mit allen Modulen des Produkts. Je Kunde und Programm ist nur ein Test möglich.</p> : <div className="cols">
<Field id="durationType" label="Laufzeit"><select id="durationType" name="durationType" key={sel?.id ?? 0} defaultValue={sel?.durationType && DURATION[sel.durationType] ? sel.durationType : 'YEAR'}>{Object.entries(DURATION).map(([k, l]) => <option key={k} value={k}>{l}</option>)}</select></Field>
<Field id="expiresAt" label="oder festes Ablaufdatum (optional)"><input id="expiresAt" name="expiresAt" type="date" /></Field>
</div>}
<div className="cols">
<Field id="maxActivations" label="Geräte gleichzeitig"><input id="maxActivations" name="maxActivations" type="number" min={1} max={1000} defaultValue={1} /></Field>
{kind === 'license' && <Field id="customerLimit" label="Kundenlimit (leer = laut Produkt)"><input id="customerLimit" name="customerLimit" type="number" min={0} /></Field>}
<Field id="keyPrefix" label="Editions-Präfix im Schlüssel (nur für Programme, die daran die Edition erkennen, z. B. Familytool)"><select id="keyPrefix" name="keyPrefix" defaultValue=""><option value="">keins</option><option value="PREMIUM">PREMIUM-…</option><option value="LIFETIME">LIFETIME…</option><option value="TRIAL">TRIAL-…</option></select></Field>
<Field id="note" label="Notiz (optional, max. 50 Zeichen)"><input id="note" name="note" maxLength={50} placeholder="z. B. Kulanz Ticket T-1003" /></Field>
</div>
</div>}
<div className="row"><button className="btn primary" type="submit" disabled={busy || (mode === 'contract' && cat.shopProducts.length === 0)}>{busy ? 'Bitte warten …' : mode === 'contract' ? 'Bestellung anlegen' : 'Lizenz vergeben'}</button><Link className="btn" href="/lizenzen">Abbrechen</Link></div>
</form>}
</>);
}
export default function Page() { return <Suspense><Vergeben /></Suspense>; }

View file

@ -0,0 +1,68 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, ResState, fmt } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { LicKind, type Lic } from '@/components/Licenses';
interface Summary { total: number; active: number; suspended: number; expired: number; expiring: number; unassigned: number }
type Filter = '' | 'active' | 'suspended' | 'expired' | 'expiring' | 'unassigned';
const soon = (d: string | null) => !!d && new Date(d).getTime() - Date.now() < 30 * 86400000 && new Date(d).getTime() > Date.now();
const devices = (l: Lic) => `${l.activationsUsed} / ${l.activationLimit ?? '∞'}`;
export default function Lizenzen() {
const { can } = useSession(); const staff = can('licenses.read'); const w = can('licenses.write');
const [list, setList] = useState<Lic[] | null>(null); const [sum, setSum] = useState<Summary | null>(null); const [err, setErr] = useState('');
const [filter, setFilter] = useState<Filter>(''); const [q, setQ] = useState(''); const [query, setQuery] = useState('');
const load = useCallback(() => {
const p = new URLSearchParams();
if (filter === 'expiring') p.set('expiring', '1'); else if (filter === 'unassigned') p.set('unassigned', '1'); else if (filter) p.set('state', filter);
if (query) p.set('q', query);
api<Lic[]>('GET', `/licenses${p.size ? `?${p}` : ''}`).then(setList).catch((e) => setErr(errMsg(e)));
}, [filter, query]);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (staff) api<Summary>('GET', '/admin/licenses/summary').then(setSum).catch(() => undefined); }, [staff]);
const tile = (f: Filter, n: number, label: string, warn = false) => (
<button type="button" className="card" onClick={() => setFilter(filter === f ? '' : f)} aria-pressed={filter === f} style={{ textAlign: 'left', cursor: 'pointer', outline: filter === f ? '2px solid var(--accent)' : undefined }}>
<div className="stat">{n}</div><p className="muted" style={{ margin: 0 }}>{label}{warn && n > 0 && <> <span className="badge warn"><span aria-hidden="true">▲</span>Prüfen</span></>}</p>
</button>);
return (<>
<div className="pagehead row between"><h1>{staff ? 'Lizenzen' : 'Meine Lizenzen'}</h1>{w && <Link className="btn primary" href="/lizenzen/neu">Lizenz vergeben</Link>}</div>
{err && <Alert kind="err">{err}</Alert>}
{staff && sum && <div className="grid" style={{ marginBottom: 16 }}>
{tile('', sum.total, 'Lizenzen gesamt')}{tile('active', sum.active, 'Aktiv')}{tile('expiring', sum.expiring, 'Laufen in 30 Tagen ab', true)}
{tile('suspended', sum.suspended, 'Gesperrt')}{tile('expired', sum.expired, 'Abgelaufen')}{sum.unassigned > 0 && tile('unassigned', sum.unassigned, 'Ohne Kunde', true)}
</div>}
{staff && <form className="row" style={{ marginBottom: 16, gap: 8 }} onSubmit={(e) => { e.preventDefault(); setQuery(q.trim()); }}>
<input aria-label="Suche nach Kunde, Kundennummer, Programm oder Lizenz-Nr." placeholder="Kunde, Kundennummer, Programm …" value={q} onChange={(e) => setQ(e.target.value)} style={{ maxWidth: 360 }} />
<button className="btn" type="submit">Suchen</button>
{(query || filter) && <button className="btn" type="button" onClick={() => { setQ(''); setQuery(''); setFilter(''); }}>Filter zurücksetzen</button>}
</form>}
{list === null ? <div className="card"><p className="muted" role="status">Wird geladen …</p></div>
: list.length === 0 ? <div className="card"><Empty title={filter || query ? 'Keine passenden Lizenzen' : 'Noch keine Lizenzen'}>{staff ? (w ? 'Über „Lizenz vergeben“ eine Lizenz an einen Kunden ausgeben.' : '') : 'Für Ihre Organisation sind noch keine Lizenzen hinterlegt.'}</Empty></div>
: staff ? (
<div className="card"><div className="tablewrap"><table><thead><tr><th>Lizenz</th><th>Kunde</th><th>Status</th><th>Gültig bis</th><th>Geräte</th><th>Vertrag</th></tr></thead><tbody>
{list.map((l) => <tr key={l.id}>
<td><Link href={`/lizenzen/${l.id}`}>{l.program ?? l.name}{l.product ? ` · ${l.product}` : ''}</Link><LicKind l={l} /><div className="mono small muted">{l.keyMasked}</div></td>
<td>{l.orgId ? <Link href={`/admin/kunden/${l.orgId}`}>{l.customerNumber} · {l.orgName}</Link> : <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span>}</td>
<td><ResState value={l.state} /></td>
<td>{l.validUntil ? <>{fmt(l.validUntil)}{soon(l.validUntil) && l.state === 'active' && <> <span className="badge warn"><span aria-hidden="true">▲</span>bald</span></>}</> : 'unbefristet'}</td>
<td>{devices(l)}</td>
<td>{l.contractId ? <Link href={`/vertraege/${l.contractId}`}>{l.contractNumber}</Link> : <span className="muted small">ohne Berechnung</span>}</td>
</tr>)}</tbody></table></div></div>
) : (
<div className="productgrid">{list.map((l) => (
<div className="productcard" key={l.id}>
<div className="row between"><h3><Link href={`/lizenzen/${l.id}`}>{l.program ?? l.name}</Link></h3><ResState value={l.state} /></div>
<div className="id">{l.product ?? l.edition}<LicKind l={l} /></div>
<div className="small muted">Gültig bis: {l.validUntil ? fmt(l.validUntil) : 'unbefristet'}</div>
<div className="small muted">Geräte: {devices(l)}</div>
<div style={{ marginTop: 4 }}><SecretField resourceId={l.id} compact /></div>
<div style={{ marginTop: 8 }}><Link className="btn small" href={`/lizenzen/${l.id}`}>Details und Geräte</Link></div>
</div>))}</div>
)}
</>);
}

View file

@ -0,0 +1,16 @@
'use client';
/** Gemeinsame Typen und Anzeigebausteine der Lizenzverwaltung (Übersicht, Detail, Vergabe). */
export interface Lic {
id: string; name: string; state: string; validFrom: string | null; validUntil: string | null; syncedAt: string; missing: boolean; orgId: string | null; orgName: string | null; customerNumber: string | null;
contractId: string | null; contractNumber: string | null; program: string | null; product: string | null; edition: string | null; keyMasked: string | null;
activationsUsed: number; activationLimit: number | null; trial: boolean; trialPending: boolean; addon: boolean; revoked: boolean;
}
/** Art der Lizenz als kleines Badge (Test, Add-on, Widerrufen). */
export function LicKind({ l }: { l: Pick<Lic, 'trial' | 'trialPending' | 'addon' | 'revoked'> }) {
return (<>
{l.trial && <> <span className="badge info">{l.trialPending ? 'Test (startet bei Aktivierung)' : 'Test'}</span></>}
{l.addon && <> <span className="badge info">Add-on</span></>}
{l.revoked && <> <span className="badge err"><span aria-hidden="true">✕</span>Widerrufen</span></>}
</>);
}

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>;

View file

@ -1,15 +1,18 @@
import { ConnectorError, CONTRACT_VERSION, httpJson, maskKey, type ActionName, type Capability, type CatalogItem, type Connector, type ConnectorContext, type HttpOptions, type NormalizedResource, type ResourceState } from '@kc/connector-sdk';
/** Rohdaten des eigenen Lizenzsystems (FastAPI, siehe /var/www/html/licensing). Bleiben im Connector. */
interface RawActivation { hardware_id: string; ip_address?: string | null; last_seen_ip?: string | null; activated_at: string; last_seen_at: string }
interface RawLicense {
export interface RawActivation { id?: number; hardware_id: string | null; instance_id?: string | null; environment?: string | null; ip_address?: string | null; last_seen_ip?: string | null; product_version?: string | null; is_active?: boolean; activated_at: string; last_seen_at: string }
export interface RawLicense {
id: number; program_id: number; product_id?: number | null; license_key: string; user_limit: number | null; duration_type: string; starts_at: string | null; expires_at: string | null; is_active: boolean;
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; license_type?: string | null; activation_limit?: number | null;
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null } | null;
status?: string | null; blocked?: boolean | null; suspended_at?: string | null; revoked_at?: string | null; revoke_reason?: string | null; license_type?: string | null; activation_limit?: number | null; max_activations?: number | null;
activation?: RawActivation | null; activations?: RawActivation[] | null; product?: { name?: string | null; product_type?: string | null } | null;
plan_key?: string | null; modules?: string[] | null; customer_limit?: number | null; entitlement_version?: number | null; parent_license_id?: number | null; trial_pending?: boolean | null;
grace_days?: number | null; effective_grace_days?: number | null; source?: string | null; customer_name?: string | null; customer_email?: string | null; order_ref?: string | null; external_ref?: string | null;
last_check_at?: string | null; created_at?: string | null;
}
interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null }
interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
interface RawProgram { id: number; name: string; description?: string | null }
export interface RawProduct { id: number; name: string; description?: string | null; price?: string | number | null; currency?: string | null; duration_type?: string | null; user_limit?: number | null; is_active?: boolean; features?: string | null; modules?: string | null; badge?: string | null; product_type?: string | null; plan_key?: string | null; customer_limit?: number | null }
export interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
export interface RawProgram { id: number; name: string; description?: string | null }
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
export function localToUtcIso(naive: string | null | undefined, tz: string): string | null {
@ -31,6 +34,32 @@ const toLocalNaive = (iso: string, tz: string): string => {
/** Edition wie im Familytool: Präfix des Schlüssels (PREMIUM-, TRIAL-, LIFETIME…), sonst UNLIMITED. */
export const editionOf = (key: string): string => { const k = key.toUpperCase(); return k.startsWith('LIFETIME') ? 'LIFETIME' : k.startsWith('PREMIUM-') ? 'PREMIUM' : k.startsWith('TRIAL-') ? 'TRIAL' : 'UNLIMITED'; };
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
function stateOf(l: RawLicense, until: string | null, now: Date): ResourceState {
const st = String(l.status ?? '').toLowerCase();
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
if (st === 'expired' || (until && new Date(until) < now)) return 'expired';
return 'active';
}
/** Normalisiert eine Lizenz des Lizenzsystems (für Abgleich, Bereitstellung und die Lizenzverwaltung). */
export function mapLicense(l: RawLicense, programs: Map<number, string>, zone: string, now: Date = new Date()): NormalizedResource {
const until = localToUtcIso(l.expires_at, zone);
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
const act = l.activation ?? l.activations?.[0] ?? null;
return {
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until, now),
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
limits: { users: l.user_limit },
details: {
program, programId: l.program_id, product: l.product?.name ?? null, productId: l.product_id ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
providerStatus: l.status ?? null, revoked: !!l.revoked_at, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
plan: l.plan_key ?? null, customerLimit: l.customer_limit ?? null, parentLicenseId: l.parent_license_id ?? null, addon: l.parent_license_id != null || l.product?.product_type === 'ADDON',
trial: l.duration_type === 'TRIAL', trialPending: !!l.trial_pending,
activation: act ? { hardwareIdMasked: act.hardware_id ? maskKey(act.hardware_id) : null, activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
},
};
}
export interface LicensingDeps extends HttpOptions { now?: () => Date }
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
const PREFIXES = ['PREMIUM', 'TRIAL', 'LIFETIME'];
@ -78,28 +107,7 @@ export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
catch (e) { if (e instanceof ConnectorError && e.code === 'AUTH_FAILED' && hasAuth(ctx) && !hasToken(ctx)) return go(true); throw e; }
}
/** Zustand: gesperrt/widerrufen/blockiert erkennt auch die neuen Felder des Lizenzsystems (status, blocked, suspended_at, revoked_at). */
const stateOf = (l: RawLicense, until: string | null): ResourceState => {
const st = String(l.status ?? '').toLowerCase();
if (l.is_active === false || l.blocked || l.suspended_at || l.revoked_at || ['suspended', 'revoked', 'blocked', 'inactive', 'canceled', 'cancelled'].includes(st)) return 'suspended';
if (st === 'expired' || (until && new Date(until) < now())) return 'expired';
return 'active';
};
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => {
const until = localToUtcIso(l.expires_at, zone);
const program = programs.get(l.program_id) ?? `Programm ${l.program_id}`;
const act = l.activation ?? l.activations?.[0] ?? null;
return {
externalRef: String(l.id), type: 'license', name: `${program}${l.product?.name ? ` ${l.product.name}` : ''} · ${maskKey(l.license_key)}`, state: stateOf(l, until),
validFrom: localToUtcIso(l.starts_at, zone), validUntil: until,
limits: { users: l.user_limit },
details: {
program, product: l.product?.name ?? null, licenseKeyMasked: maskKey(l.license_key), durationType: l.duration_type, edition: l.product?.name ?? editionOf(l.license_key),
providerStatus: l.status ?? null, activationsUsed: l.activations?.length ?? (l.activation ? 1 : 0), activationLimit: l.activation_limit ?? null,
activation: act ? { hardwareIdMasked: maskKey(act.hardware_id), activatedAt: localToUtcIso(act.activated_at, zone), lastCheckAt: localToUtcIso(act.last_seen_at, zone), lastSeenIp: act.last_seen_ip ?? null } : null,
},
};
};
const map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => mapLicense(l, programs, zone, now());
return {
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
@ -235,3 +243,4 @@ export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
};
}
export const licensingConnector = createLicensingConnector();
export * from './admin.js';

View file

@ -60,6 +60,8 @@ export async function syncInstance(instanceId: string, correlationId: string): P
}
}
/** Ressource anlegen/aktualisieren (Abgleich, Bereitstellung, Lizenzvergabe). Liefert die lokale ID. */
export async function upsertResource(instanceId: string, r: NormalizedResource): Promise<string> { return upsert(instanceId, r); }
async function upsert(instanceId: string, r: NormalizedResource): Promise<string> {
await run(
`INSERT INTO resources (id, instance_id, external_ref, type, name, state, valid_from, valid_until, data_json, synced_at)
@ -111,6 +113,7 @@ export async function scheduleDueSyncs(enqueue: (type: string, payload: unknown,
return due.length;
}
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
export { createLicensingAdmin, type LicensingAdmin, type LicenseDetail, type LicensingCatalog } from '@kc/connector-licensing';
export type { ActionName, Capability };