From a322166d01f40af6b03ea83d432d4944c49ffed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:53:20 +0200 Subject: [PATCH] =?UTF-8?q?Lizenzverwaltung:=20=C3=9Cbersicht,=20Vergabe,?= =?UTF-8?q?=20Aktivierungen,=20Lebenszyklus=20und=20Kundensicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/index.ts | 3 +- apps/api/src/modules/licensing/index.ts | 266 ++++++++++++++++++ apps/web/src/app/(app)/dashboard/page.tsx | 3 + apps/web/src/app/(app)/layout.tsx | 2 +- apps/web/src/app/(app)/lizenzen/[id]/page.tsx | 170 +++++++++++ apps/web/src/app/(app)/lizenzen/neu/page.tsx | 107 +++++++ apps/web/src/app/(app)/lizenzen/page.tsx | 68 +++++ apps/web/src/components/Licenses.tsx | 16 ++ packages/connector-licensing/src/admin.ts | 110 ++++++++ packages/connector-licensing/src/index.ts | 67 +++-- packages/connectors/src/index.ts | 3 + 11 files changed, 784 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/modules/licensing/index.ts create mode 100644 apps/web/src/app/(app)/lizenzen/[id]/page.tsx create mode 100644 apps/web/src/app/(app)/lizenzen/neu/page.tsx create mode 100644 apps/web/src/app/(app)/lizenzen/page.tsx create mode 100644 apps/web/src/components/Licenses.tsx create mode 100644 packages/connector-licensing/src/admin.ts diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index fb61559..f0f59d6 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -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]; diff --git a/apps/api/src/modules/licensing/index.ts b/apps/api/src/modules/licensing/index.ts new file mode 100644 index 0000000..038531a --- /dev/null +++ b/apps/api/src/modules/licensing/index.ts @@ -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 = (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; limits?: Record }>(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 { + 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[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> | 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((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 = {}; + 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 = { 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 }; + }); + }, +}; diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index adc8776..38cc7a7 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -9,6 +9,7 @@ interface Sys { jobs: Record; 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(null); const [sys, setSys] = useState(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('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e))); if (can('jobs.read')) api('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() { ) : (<>
{customers !== null &&
{customers}

Kunden

Kunden verwalten
} + {licenses &&
{licenses.active}

Aktive Lizenzen

{licenses.expiring > 0 &&

{licenses.expiring} laufen in 30 Tagen ab

}{licenses.unassigned > 0 &&

{licenses.unassigned} ohne Kunde

}Lizenzen verwalten
} {sys &&
{sys.jobs.scheduled ?? 0}

Wartende Aufträge

} {sys?.backup &&

Backup

Details und Einstellungen

{!sys.backup.configured ? <>

Nicht eingerichtet.

Kein Backup diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index dd1eeb5..cd93719 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -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 = (