Compare commits

...

5 commits

Author SHA1 Message Date
Claude
17267b7c05 KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen
Testmodus (ohne gültige Lizenz): 1 Personal-Konto, 2 Kunden; Discord-Bot,
E-Mail-Posteingang (IMAP), DATEV-Export und Backups auf externe Ziele nur mit
Lizenz. Mit Lizenz gelten deren Grenzen und Module (Lizenz ohne Modulliste =
voller Umfang). Bestehende Daten bleiben bei Ablauf vollständig nutzbar, nur
Neuanlagen über die Grenzen und die Zusatzfunktionen pausieren.

- Edition zentral in @kc/platform/license (getEdition, hasFeature,
  assertCustomerCapacity, assertStaffCapacity), auch in der CLI durchgesetzt
- Einstellungen → Lizenz zeigt die Edition, Hinweisbanner im Testmodus
- README mit Installation, Update und Betrieb; systemd-Units unter ops/systemd
- .env.example bereinigt (Kommentare nicht mehr hinter Werten, veraltete
  SMTP/Discord-Variablen entfernt; beides wird in der Oberfläche eingestellt)
- LICENSE.md: Nutzungsbedingungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:53 +02:00
Claude
fdd51b18e2 Lizenzen: Kunde auf der Detailseite zuweisen, ändern oder Zuordnung lösen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:06:14 +02:00
Claude
a322166d01 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>
2026-10-02 12:54:12 +02:00
Claude
9336f46d0a Discord: Support-Antworten im Ticket-Kanal als Antwort ins Ticket übernehmen
Nachrichten von verknüpftem Personal im Ticket-Kanal/-Thread werden wie eine
Antwort im Kundencenter behandelt: Support-Nachricht, Status 'wartet auf Kunde',
Mail an den Ersteller, Audit-Eintrag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:29:36 +02:00
Claude
8ef0b3bc05 Discord: Tickets als eigene Kanäle in einer Kategorie, Ticket-Meldungen nur für den Support
Mit gesetzter Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal,
sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften
Mitglieder des Kunden. Ein minütlicher Abgleich im Worker legt fehlende Kanäle an
(auch für Tickets aus Mail-Eingang/Vertragsverlängerung) und entfernt Kanäle
geschlossener Tickets. Ticket-Meldungen gehen in einen eigenen Support-Kanal
(#ticket-log, wird automatisch angelegt) statt in den Systemmeldungs-Kanal.
Ohne Kategorie bleibt das bisherige Verhalten (private Threads) unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:11:51 +02:00
40 changed files with 1342 additions and 129 deletions

View file

@ -1,21 +1,21 @@
# Beispielwerte OHNE Geheimnisse. Echte Werte liegen in /etc/kundencenter/*.env (chmod 600).
# Vorlage OHNE Geheimnisse. Echte Werte nach /etc/kundencenter/db.env und /etc/kundencenter/app.env (chmod 600, root).
# Wichtig: Kommentare nur in eigenen Zeilen, nie hinter einem Wert.
# ---- /etc/kundencenter/db.env ----
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=kundencenter
DB_USER=kundencenter
DB_PASSWORD=
KC_SECRET_KEY= # 32 Byte base64 (openssl rand -base64 32); Verschluesselung von TOTP-Secrets
KC_BASE_URL=http://localhost:4101
# ---- /etc/kundencenter/app.env ----
# Master-Schlüssel für 2FA- und Zugangsdaten-Verschlüsselung: openssl rand -base64 32
# Unbedingt sichern – ohne ihn sind verschlüsselte Daten nach einer Wiederherstellung unlesbar.
KC_SECRET_KEY=
# Exakt die Adresse, unter der das Kundencenter im Browser aufgerufen wird (Origin-Prüfung gegen CSRF)
KC_BASE_URL=https://kundencenter.example.de
KC_API_PORT=4100
KC_NODE_ENV=development
# optional: SMTP (sonst wird nur im Mail-Protokoll abgelegt)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# optional: Discord (sonst bleibt der Bot deaktiviert)
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
DISCORD_ADMIN_CHANNEL_ID=
DISCORD_STAFF_USER_IDS= # kommagetrennt, dürfen /kc-Befehle nutzen
KC_NODE_ENV=production
# Kennung des Programms "Kundencenter" beim Lizenzsystem (kein Geheimnis, in jeder Installation gleich).
# Den eigenen Lizenzschlüssel trägt man danach in der Oberfläche ein (Einstellungen → Lizenz).
LICENSE_PROGRAM_KEY=da78e720-7180-4c44-84dd-50bfc83fe1ef

18
LICENSE.md Normal file
View file

@ -0,0 +1,18 @@
# Nutzungsbedingungen KC@FlessingLabs
Copyright © 2026 FlessingLabs. Alle Rechte vorbehalten.
Der Quellcode von KC@FlessingLabs („Software“) ist öffentlich einsehbar. Er ist **keine** Open-Source-Software.
1. **Testmodus:** Die Software darf ohne Lizenz kostenlos installiert und im Testmodus betrieben werden
(1 Personal-Konto, 2 Kunden, ohne lizenzpflichtige Zusatzfunktionen).
2. **Lizenz:** Der Betrieb über die Grenzen des Testmodus hinaus und die Nutzung der lizenzpflichtigen Funktionen
(u. a. Discord-Bot, E-Mail-Posteingang, DATEV-Export, Backups auf externe Ziele) setzen eine gültige Lizenz von
FlessingLabs voraus. Umfang und Laufzeit ergeben sich aus der jeweiligen Lizenz.
3. **Nicht gestattet** sind das Umgehen, Entfernen oder Verändern der Lizenzprüfung sowie die Weitergabe, der Verkauf
oder das Anbieten der Software oder veränderter Fassungen als eigenes Produkt oder Dienst für Dritte.
4. **Eigene Anpassungen** für den Betrieb der eigenen Installation sind erlaubt.
5. **Gewährleistung:** Die Software wird ohne Gewähr bereitgestellt, soweit gesetzlich zulässig. Für Datensicherung
ist der Betreiber selbst verantwortlich.
Lizenzen und Fragen: https://flessinglabs.com

128
README.md
View file

@ -1,52 +1,110 @@
# Kundencenter
# KC@FlessingLabs – Kundencenter
Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, TypeScript).
Selbst gehostetes Kundencenter für Hosting-Anbieter, Agenturen und Software-Hersteller: Kunden, Produkte, Bestellungen,
Verträge, Rechnungen, Support-Tickets und Lizenzen in einer Oberfläche – mit Anbindung an KeyHelp und an das
FlessingLabs-Lizenzsystem. Modularer Monolith in TypeScript (Fastify, Next.js, MariaDB).
| Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe |
|---|---|---|---|
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Policy, Audit |
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot |
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, Proxy `/api/*` → API |
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Rechte, Audit |
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot, E-Mail-Posteingang |
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, leitet `/api/*` an die API weiter |
## Stand (Grundsystem)
Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen),
Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional).
Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`).
Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung).
Rechnungen (`docs/rechnungen.md`): Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF-Erzeugung, Firmenstammdaten unter Einstellungen → Firma.
Noch **nicht** vorhanden: Zahlungsanbieter-Anbindung, automatische wiederkehrende Rechnungsstellung, E-Mail-Versand von Rechnungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung).
## Funktionen
- **Konten und Sicherheit:** Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Einladungen, Rollen für Personal und Kunden, Audit-Protokoll mit Hash-Kette.
- **Kunden:** Privat- und Geschäftskunden, Rechnungsanschrift, Übernahme aus KeyHelp, Kundenansicht („Als Kunde ansehen“).
- **Produkte, Bestellungen, Verträge:** versionierte Produkte, Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Laufzeiten, Kündigung, automatische Verlängerung mit Erinnerung (`docs/produkte-bestellungen-vertraege.md`).
- **Bereitstellung über Connectoren:** KeyHelp (Hosting-Konten, Domains, Postfächer, Datenbanken) und Lizenzsystem (`docs/connector-vertrag.md`, `docs/connector-keyhelp.md`).
- **Lizenzverwaltung:** Übersicht, Vergabe mit Vertrag oder ohne Berechnung (Lizenz, Test, Add-on), Geräte freigeben, Sperren/Verlängern/Widerrufen, Limits und Funktionsumfang; Kunden sehen ihre Lizenzen und geben eigene Geräte frei.
- **Rechnungen:** Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF, DATEV-Export (`docs/rechnungen.md`).
- **Support:** Tickets je Kunde mit internen Notizen und Anhängen, E-Mail-Posteingang (IMAP), Discord-Bot mit eigenem Kanal je Ticket.
- **Betrieb:** verschlüsselte tägliche Backups mit automatischem Wiederherstellungstest (`docs/betrieb-backup-restore.md`).
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).
Sie werden in `modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
Neue Fähigkeiten (Connectoren, Rechnungen, Tickets) kommen als weitere Module hinzu.
## Testmodus und Lizenz
Ohne Lizenzschlüssel läuft das Kundencenter im **Testmodus**:
## Konfiguration
Geheimnisse liegen **nicht** im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600): `db.env`, `app.env`, `discord.env`, optional SMTP in `app.env`.
Vorlage: `.env.example`. Wichtig: `KC_BASE_URL` muss exakt der Browser-URL entsprechen (Origin-Prüfung gegen CSRF).
| | Testmodus | Mit Lizenz |
|---|---|---|
| Personal-Konten | 1 | laut Lizenz |
| Kunden | 2 | laut Lizenz |
| Discord-Bot, E-Mail-Posteingang (IMAP), DATEV-Export, Backups auf externe Ziele | – | ✓ |
Alles andere ist im Testmodus voll nutzbar. Eine Lizenz gibt es bei FlessingLabs (https://flessinglabs.com); den Schlüssel
unter **Einstellungen → Lizenz** eintragen, er wird regelmäßig bei licensing.flessinglabs.com geprüft (7 Tage offline
möglich). Läuft eine Lizenz ab, gilt wieder der Testmodus – bestehende Daten bleiben vollständig erhalten und nutzbar,
nur Neuanlagen über die Grenzen hinaus und die Zusatzfunktionen pausieren. Nutzungsbedingungen: `LICENSE.md`.
## Installation (Debian/Ubuntu)
Voraussetzungen: Node.js ≥ 22, pnpm 10, MariaDB ≥ 10.6, ein Reverse-Proxy mit HTTPS (Apache, nginx, Caddy …),
für Backups `gpg` und `rclone`.
```bash
# 1. Benutzer und Code
useradd --system --home-dir /var/lib/kundencenter --shell /usr/sbin/nologin kundencenter
git clone https://forge.flessinghome.de/flessinglabs/kundencenter.git /srv/kundencenter
chown -R kundencenter: /srv/kundencenter
cd /srv/kundencenter && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build
# 2. Datenbank (Zeichensatz utf8mb4 / utf8mb4_unicode_ci ist wichtig)
mysql -e "CREATE DATABASE kundencenter CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'kundencenter'@'localhost' IDENTIFIED BY '<passwort>';
GRANT ALL ON kundencenter.* TO 'kundencenter'@'localhost';
GRANT ALL ON kundencenter_restoretest.* TO 'kundencenter'@'localhost';"
# 3. Konfiguration (Vorlage: .env.example; Kommentare nie hinter einen Wert schreiben)
install -d -m 700 /etc/kundencenter
install -d -o kundencenter -m 755 /var/lib/kundencenter/requests
install -d -o kundencenter -m 750 /var/lib/kundencenter/ticket-attachments
install -d -m 700 /var/backups/kundencenter
# /etc/kundencenter/db.env und /etc/kundencenter/app.env anlegen, chmod 600
# 4. Schema und erster Superadmin (Passwort wird abgefragt)
cd /srv/kundencenter && pnpm migrate
pnpm cli create-superadmin --email=admin@example.de --name="Vorname Nachname"
# 5. Dienste
cp ops/systemd/kc-* /etc/systemd/system/ && systemctl daemon-reload
systemctl enable --now kc-api kc-web kc-worker kc-backup.timer kc-restore-test.timer kc-backup-request.path
```
Reverse-Proxy: alles auf `http://127.0.0.1:4101` weiterleiten und `X-Forwarded-Proto: https` setzen. Beispiel Apache:
```apache
ProxyPass / http://127.0.0.1:4101/
ProxyPassReverse / http://127.0.0.1:4101/
RequestHeader set X-Forwarded-Proto "https"
```
Danach anmelden, 2FA einrichten, unter **Einstellungen** Firma, E-Mail (SMTP) und Backup-Passwort hinterlegen.
## Update
```bash
cd /srv/kundencenter && systemctl start kc-backup # vorher sichern
sudo -u kundencenter git pull && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build
pnpm migrate && systemctl restart kc-api kc-web kc-worker
```
## Betrieb
```bash
systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always)
journalctl -u kc-api -f # Logs
pnpm install && pnpm build # Update: danach chown -R kundencenter und systemctl restart kc-*
pnpm migrate # Migrationen (migrations/*.sql, einmalig je Datei)
pnpm cli create-superadmin --email=… --name=… --password=…
pnpm cli reset-password --email=… # Passwort zurücksetzen
```
Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`.
## Tests
`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt).
Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette/Maskierung.
Geheimnisse liegen nie im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600). `KC_BASE_URL` muss exakt der Browser-Adresse entsprechen.
## Discord-Bot
In `/etc/kundencenter/discord.env` setzen: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_ADMIN_CHANNEL_ID`, `DISCORD_STAFF_USER_IDS` (kommagetrennt), dann `systemctl restart kc-worker`.
Befehle: `/kc-status`, `/kc-kunde suche:<Text>` (nur für freigegebene Discord-User, Antworten nur für sie sichtbar). In Kanäle gehen nur Ereignisse ohne personenbezogene Daten (z. B. „Neuer Kunde angelegt: K-10001“).
Unter **Einstellungen → Discord** Bot-Token, Server-ID und Kanäle eintragen (Anleitung auf der Seite). Mit einer
Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal, sichtbar nur für die Support-Rollen und den Kunden.
Kunden und Personal verknüpfen ihr Discord-Konto unter „Mein Konto“. Erfordert eine Lizenz.
## Backup / Restore (Datenbank)
```bash
set -a; . /etc/kundencenter/db.env; set +a
MYSQL_PWD=$DB_PASSWORD mysqldump -h127.0.0.1 -u$DB_USER --single-transaction --routines $DB_NAME | gzip > kundencenter-$(date +%F).sql.gz
gunzip -c kundencenter-DATUM.sql.gz | MYSQL_PWD=$DB_PASSWORD mysql -h127.0.0.1 -u$DB_USER $DB_NAME # Restore in leere DB
```
Automatisiertes, verschlüsseltes Backup mit Restore-Test ist noch offen (siehe Plane).
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`). Sie werden in
`modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
Neue Anbieter kommen als Connector unter `packages/connector-*` hinzu (`docs/connector-vertrag.md`).
## Tests
`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf
neu erstellt). Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette.
## Interne Werkzeuge
`ops/night-agent.mjs` und `docs/lizenzsystem-erweiterung/` sind Werkzeuge für die Entwicklung bei FlessingLabs und für
den Betrieb einer eigenen Installation nicht nötig.

View file

@ -3,6 +3,7 @@ import { hash } from '@node-rs/argon2';
import '../core/config.js';
import { pool, one, run } from '../core/db.js';
import { audit } from '../core/audit.js';
import { assertStaffCapacity } from '../core/license.js';
const [cmd, ...args] = process.argv.slice(2);
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
@ -41,6 +42,8 @@ if (cmd === 'create-superadmin') {
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin';
if (!email) { console.error('Nutzung: create-superadmin --email=… [--name=…] (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits (für einen Reset stattdessen: reset-password --email=…)'); process.exit(1); }
const cap = await assertStaffCapacity();
if (!cap.allowed) { console.error(cap.reason); process.exit(1); }
const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); });
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
const id = randomUUID();

View file

@ -13,6 +13,7 @@ import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '
import { loadTargets, testTarget } from '../../ops/targets.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import type { KcModule } from '../../core/module.js';
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
@ -111,6 +112,7 @@ export const backupModule: KcModule = {
app.post('/admin/backup/targets', async (req) => {
const a = requirePermission(req, 'backup.secrets');
if (!(await hasFeature('backup_remote'))) throw forbidden(featureRequiresLicense('backup_remote'), 'LICENSE_REQUIRED');
const b = targetSchema.parse(req.body);
if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
const { cfg, sec, path } = split(b); const id = randomUUID();

View file

@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js';
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { rl, config } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
@ -12,6 +13,7 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
const settingsView = (s: any) => ({
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id,
ticketCategoryId: s.ticket_category_id, ticketLogChannelId: s.ticket_log_channel_id, supportRoleIds: s.support_role_ids,
configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at,
});
const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`;
@ -30,11 +32,18 @@ export const discordModule: KcModule = {
token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
ticketChannelId: z.string().trim().regex(ID).nullable(),
// optional, damit ältere Formulare die Werte nicht versehentlich löschen
ticketCategoryId: z.string().trim().regex(ID).nullable().optional(), ticketLogChannelId: z.string().trim().regex(ID).nullable().optional(),
supportRoleIds: z.string().trim().regex(/^\d{15,25}(\s*,\s*\d{15,25})*$/).max(500).nullable().optional(),
enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body);
if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); }
if (b.ticketLogChannelId !== undefined) { sets.push('ticket_log_channel_id = ?'); params.push(b.ticketLogChannelId); }
if (b.supportRoleIds !== undefined) { sets.push('support_role_ids = ?'); params.push(b.supportRoleIds ? b.supportRoleIds.split(',').map((x) => x.trim()).join(',') : null); }
if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); }
if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); }
await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params);
@ -96,6 +105,7 @@ export const discordModule: KcModule = {
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
app.get('/discord/oauth/start', async (req, reply) => {
const a = requireAuth(req);
if (!(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
const state = randomToken(32);

View file

@ -8,6 +8,7 @@ import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js';
import { assertStaffCapacity } from '../../core/license.js';
import { decrypt, encrypt, sha256 } from '../../core/crypto.js';
import { can, staffPermissions } from '../../core/policy.js';
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
@ -239,6 +240,8 @@ export const identityModule: KcModule = {
const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body);
if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS');
const cap = await assertStaffCapacity();
if (!cap.allowed) throw forbidden(cap.reason!, 'STAFF_LIMIT_REACHED');
const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole }));
const mail = await mailInvite(b.email, b.name, inv.token);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } });

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

@ -12,6 +12,7 @@ import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
import { config } from '../../core/config.js';
@ -487,6 +488,7 @@ export const invoicesModule: KcModule = {
* Steuerschlüssel durch das Erlöskonto selbst festgelegt ist (kein geratener BU-Schlüssel nötig). */
app.get('/admin/invoices/export.datev', async (req, reply) => {
const a = requirePermission(req, 'invoices.read');
if (!(await hasFeature('datev'))) throw forbidden(featureRequiresLicense('datev'), 'LICENSE_REQUIRED');
const q = exportQuery.parse(req.query);
const d = await datevSettings();
if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE');

View file

@ -4,7 +4,7 @@ import { one } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { checkLicenseNow, getEntitlement, setLicenseKey, LicenseCheckError } from '../../core/license.js';
import { checkLicenseNow, getEdition, getEntitlement, setLicenseKey, FEATURE_LABEL, LICENSED_FEATURES, LicenseCheckError } from '../../core/license.js';
import type { KcModule } from '../../core/module.js';
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
@ -20,7 +20,10 @@ export const licenseModule: KcModule = {
const ent = await getEntitlement();
const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1');
const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')");
return { ...ent, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
const staff = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const ed = await getEdition();
const edition = { ...ed, staffCount: Number(staff?.n ?? 0), allFeatures: LICENSED_FEATURES.map((f) => ({ key: f, label: FEATURE_LABEL[f], enabled: ed.features.includes(f) })) };
return { ...ent, edition, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) };
});
app.put('/admin/license/settings', async (req) => {

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

@ -6,6 +6,7 @@ import { audit } from '../../core/audit.js';
import { encrypt, decrypt } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { rl } from '../../core/config.js';
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js';
@ -124,6 +125,7 @@ export const mailModule: KcModule = {
secureMode: z.enum(['tls', 'starttls']).default('tls'), username: z.string().trim().max(200).nullable(),
password: z.string().max(500).nullable().optional(), folder: z.string().trim().min(1).max(200).default('INBOX'), enabled: z.boolean().default(false),
}).parse(req.body);
if (b.enabled && !(await hasFeature('imap'))) throw badRequest(featureRequiresLicense('imap'), 'LICENSE_REQUIRED');
const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'folder = ?', 'enabled = ?', 'updated_by = ?'];
const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.folder, b.enabled ? 1 : 0, a.user.id];
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }

View file

@ -13,6 +13,7 @@ import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js';
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
import { getBackupPassword } from './settings.js';
import { buildRemote, loadTargets, friendly, type Remote } from './targets.js';
import { hasFeature } from '../core/license.js';
/** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */
export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string }
@ -119,9 +120,11 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonN
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
targets.push({ name: `lokal (${c.dir})`, ok: true });
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
const remoteAllowed = await hasFeature('backup_remote');
if (!remoteAllowed && ((await loadTargets(true).catch(() => [])).length || c.remotes.length)) targets.push({ name: 'Externe Ziele', ok: true, error: 'übersprungen: nur mit Lizenz' });
if (remoteAllowed) try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); }
for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
if (remoteAllowed) for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} });
const okDests: typeof dests = [];
for (const d of dests) {
try {

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

@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string }
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
function Guide({ redirectUri }: { redirectUri: string }) {
@ -13,7 +13,7 @@ function Guide({ redirectUri }: { redirectUri: string }) {
<ol className="small" style={{ margin: '16px 0 0', paddingLeft: 20, display: 'grid', gap: 12 }}>
<li><strong>Anwendung anlegen:</strong> Auf <a href="https://discord.com/developers/applications" target="_blank" rel="noreferrer">discord.com/developers/applications</a> auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).</li>
<li><strong>Bot-Token erzeugen:</strong> Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die <strong>Message Content Intent</strong> aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).</li>
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>Bot einladen:</strong> Im Reiter „OAuth2 → URL Generator“ die Scopes <code className="mono">bot</code> und <code className="mono">applications.commands</code> ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen (für Tickets als eigene Kanäle zusätzlich „Manage Channels“, „Manage Roles“ und „Attach Files“). Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.</li>
<li><strong>Kunden-Anmeldung (OAuth) einrichten:</strong> Im Reiter „OAuth2 → General“ die <strong>Client ID</strong> und (unter „Reset Secret“) das <strong>Client Secret</strong> kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: <code className="mono">{redirectUri}</code></li>
<li><strong>IDs ermitteln:</strong> In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.</li>
<li><strong>Hier eintragen:</strong> Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.</li>
@ -37,7 +37,7 @@ export default function DiscordSettings() {
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? '');
try {
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), ticketCategoryId: v('ticketCategoryId'), ticketLogChannelId: v('ticketLogChannelId'), supportRoleIds: v('supportRoleIds'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
@ -69,7 +69,14 @@ export default function DiscordSettings() {
<Field id="token" label="Bot-Token" hint={s.hasToken ? 'Ein Token ist gespeichert. Leer lassen, um ihn unverändert zu lassen.' : 'Noch kein Token gespeichert.'}><input id="token" name="token" type="password" autoComplete="new-password" disabled={!w} /></Field>
<Field id="guildId" label="Server-ID (Guild-ID)"><input id="guildId" name="guildId" defaultValue={s.guildId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="adminChannelId" label="Kanal-ID für Systemmeldungen"><input id="adminChannelId" name="adminChannelId" defaultValue={s.adminChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads"><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="ticketChannelId" label="Kanal-ID für Ticket-Threads" hint="Nur ohne Ticket-Kategorie: private Threads, nur für Kunden mit verknüpftem Discord."><input id="ticketChannelId" name="ticketChannelId" defaultValue={s.ticketChannelId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
</div>
<h3>Tickets als eigene Kanäle</h3>
<p className="muted small">Mit einer Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal darin. Sehen können ihn nur die Support-Rollen und die Mitglieder des Kunden, die ihr Discord verknüpft haben. Beim Schließen wird der Kanal entfernt, der Verlauf bleibt im Kundencenter. Meldungen zu neuen Tickets gehen in den Support-Kanal (wird bei leerem Feld automatisch als <code className="mono">#ticket-log</code> angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.</p>
<div className="cols">
<Field id="ticketCategoryId" label="Kategorie-ID für Tickets"><input id="ticketCategoryId" name="ticketCategoryId" defaultValue={s.ticketCategoryId ?? ''} placeholder="123456789012345678" disabled={!w} /></Field>
<Field id="supportRoleIds" label="Rollen-IDs Support (kommagetrennt)"><input id="supportRoleIds" name="supportRoleIds" defaultValue={s.supportRoleIds ?? ''} placeholder="123456789012345678, 234567890123456789" disabled={!w} /></Field>
<Field id="ticketLogChannelId" label="Kanal-ID für Ticket-Meldungen (Support)"><input id="ticketLogChannelId" name="ticketLogChannelId" defaultValue={s.ticketLogChannelId ?? ''} placeholder="leer = automatisch anlegen" disabled={!w} /></Field>
</div>
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Bot aktiv</label>
<p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>

View file

@ -9,6 +9,7 @@ interface Status {
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
customerCount: number;
edition: { mode: 'licensed' | 'trial'; reason: string | null; customerLimit: number | null; staffLimit: number | null; staffCount: number; allFeatures: { key: string; label: string; enabled: boolean }[] };
}
const SOURCE_LABEL: Record<Status['source'], string> = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' };
const MODULE_LABEL: Record<string, string> = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' };
@ -38,13 +39,21 @@ export default function LizenzPage() {
<h2>Lizenz</h2>
<p className="muted">Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{!s.configured && <Alert kind="warn">Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.</Alert>}
{s.edition.mode === 'trial' && <Alert kind="warn"><strong>Testmodus</strong> ({s.edition.reason}): höchstens {s.edition.staffLimit} Personal-Konto und {s.edition.customerLimit} Kunden; {s.edition.allFeatures.filter((f) => !f.enabled).map((f) => f.label).join(', ')} nur mit Lizenz. Bestehende Daten bleiben vollständig nutzbar.</Alert>}
{s.configured && s.source === 'unchecked' && <Alert kind="info">Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.</Alert>}
{s.configured && s.source !== 'unchecked' && !s.valid && <Alert kind="err">Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && <Alert kind="err">Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.</Alert>}
{s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && <Alert kind="warn">Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.</Alert>}
{s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && <Alert kind="warn">Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.</Alert>}
<div className="card">
<h3>Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
<div><dt className="small muted">Personal-Konten</dt><dd>{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}</dd></div>
<div><dt className="small muted">Kunden</dt><dd>{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}</dd></div>
{s.edition.allFeatures.map((f) => <div key={f.key}><dt className="small muted">{f.label}</dt><dd>{f.enabled ? <span className="badge ok">enthalten</span> : <span className="badge lock">nur mit Lizenz</span>}</dd></div>)}
</dl>
</div>
<div className="card">
<h3>Status</h3>
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>

View file

@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
export default function AppLayout({ children }: { children: ReactNode }) {
const { me, loading, can, reload } = useSession(); const r = useRouter(); const path = usePathname(); const [open, setOpen] = useState(false);
const [trial, setTrial] = useState<string | null>(null);
useEffect(() => { if (me?.kind === 'staff' && can('license.read')) api<{ edition: { mode: string; staffLimit: number | null; customerLimit: number | null } }>('GET', '/admin/license/status').then((s) => setTrial(s.edition.mode === 'trial' ? `Testmodus: höchstens ${s.edition.staffLimit} Personal-Konto und ${s.edition.customerLimit} Kunden, ohne Discord-Bot, E-Mail-Posteingang, DATEV-Export und externe Backups.` : null)).catch(() => undefined); }, [me, can]);
const enrollNeeded = !!me?.mfaEnrollRequired;
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, r]);
@ -21,7 +23,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">
@ -53,7 +55,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
)}
<div className="shell">
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
{nav}<main id="main" className="main">{children}</main>
{nav}<main id="main" className="main">{trial && path !== '/einstellungen/lizenz' && <div className="alert warn" role="status" style={{ marginBottom: 16 }}>{trial} <Link href="/einstellungen/lizenz">Lizenz hinterlegen</Link></div>}{children}</main>
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
{bottomLink('/dashboard', 'Übersicht')}
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}

View file

@ -0,0 +1,180 @@
'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 w = can('licenses.write');
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]); const [assignTo, setAssignTo] = useState('');
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]);
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
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>}
{w && <div className="card"><h2>{d.orgId ? 'Kunde ändern' : 'Kunde zuweisen'}</h2>
<div className="row" style={{ gap: 8 }}>
<select aria-label="Kunde wählen" value={assignTo} onChange={(e) => setAssignTo(e.target.value)} style={{ minWidth: 260 }}><option value="">Kunde wählen …</option>{custs.filter((c) => c.id !== d.orgId).map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select>
<button className="btn primary" disabled={!assignTo || busy} onClick={() => { const c = custs.find((x) => x.id === assignTo); ask(d.orgId ? 'Kunde ändern' : 'Kunde zuweisen', `Die Lizenz wird ${c?.customerNumber} · ${c?.name} zugeordnet. Der Kunde sieht sie danach unter „Meine Lizenzen“ samt Schlüssel.`, async () => { await api('PATCH', `/admin/licenses/${id}/assign`, { orgId: assignTo }); setAssignTo(''); }); }}>Zuweisen</button>
{d.orgId && <button className="btn" disabled={busy} onClick={() => ask('Zuordnung lösen', 'Die Lizenz ist danach keinem Kunden mehr zugeordnet und für den bisherigen Kunden nicht mehr sichtbar.', () => api('PATCH', `/admin/licenses/${id}/assign`, { orgId: null }))}>Zuordnung lösen</button>}
</div>
<p className="small muted" style={{ marginTop: 8 }}>Ändert nur die Zuordnung im Kundencenter, nicht die Lizenz im Lizenzsystem.</p></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> : <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span>}</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

@ -1,15 +1,26 @@
import { ChannelType, Client, GatewayIntentBits, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Message } from 'discord.js';
import { ChannelType, Client, GatewayIntentBits, OverwriteType, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Guild, type Message, type OverwriteResolvable, type TextChannel } from 'discord.js';
import { randomUUID } from 'node:crypto';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
import { config } from '@kc/platform/config';
import { audit } from '@kc/platform/audit';
import { hasFeature } from '@kc/platform/license';
interface DiscordSettings { enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null }
interface DiscordSettings {
enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null;
/** Kategorie-Modus: je offenem Ticket ein eigener Kanal in dieser Kategorie (hat Vorrang vor ticketChannelId/Threads). */
ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string[];
}
async function loadSettings(): Promise<DiscordSettings> {
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
const s = rows[0];
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
return { enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null };
return {
enabled: !!s?.enabled, token, guildId: s?.guild_id ?? null, adminChannelId: s?.admin_channel_id ?? null, ticketChannelId: s?.ticket_channel_id ?? null,
ticketCategoryId: s?.ticket_category_id ?? null, ticketLogChannelId: s?.ticket_log_channel_id ?? null,
supportRoleIds: String(s?.support_role_ids ?? '').split(',').map((x: string) => x.trim()).filter(Boolean),
};
}
const notify = (event: string, extra: Record<string, unknown>, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key });
async function nextTicketNumber(): Promise<string> {
@ -63,7 +74,8 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, customer.id, 'customer', body]);
await notify('ticket.created', { number, subject: subject.slice(0, 100) }, `discord-cmd:${ticketId}`);
await postTicketMessage(ticketId, body, 'Kunde');
await i.editReply({ content: `Ticket ${number} wurde erstellt. Den zugehörigen Thread findest du weiter unten im Kanal.` });
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: ch ? `Ticket ${number} wurde erstellt: <#${ch}>` : `Ticket ${number} wurde erstellt.` });
return;
}
@ -81,7 +93,9 @@ async function handleTicketCreate(i: ChatInputCommandInteraction): Promise<void>
// Von Personal angelegt: wartet auf den Kunden, wie beim Anlegen über die Web-Oberfläche.
await pool.query('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', [ticketId, number, org.id, subject, 'pending_customer', 'normal', staff.id]);
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, staff.id, 'staff', body]);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.` });
await postTicketMessage(ticketId, body, 'Support');
const ch = await ticketChannelId(ticketId);
await i.editReply({ content: `Ticket ${number} für ${org.name} (${org.customer_number}) wurde angelegt.${ch ? ` <#${ch}>` : ''}` });
}
/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */
async function handleAssign(i: ChatInputCommandInteraction): Promise<void> {
@ -100,7 +114,8 @@ async function handleClose(i: ChatInputCommandInteraction): Promise<void> {
const t = await ticketForThread(i.channelId);
if (!t) { await i.reply({ content: 'Dieser Befehl funktioniert nur innerhalb eines Ticket-Threads.', ephemeral: true }); return; }
await pool.query("UPDATE tickets SET status = 'closed', closed_at = UTC_TIMESTAMP(3) WHERE id = ?", [t.ticket_id]);
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.` });
const categoryMode = !!(await loadSettings()).ticketCategoryId;
await i.reply({ content: `Ticket ${t.number} wurde geschlossen.${categoryMode ? ' Dieser Kanal wird innerhalb einer Minute entfernt, der Verlauf bleibt im Kundencenter.' : ''}` });
}
async function handle(i: ChatInputCommandInteraction): Promise<void> {
@ -120,28 +135,143 @@ async function handle(i: ChatInputCommandInteraction): Promise<void> {
}
}
/** Nachricht in einem Ticket-Thread von einem Kunden: als Ticket-Nachricht übernehmen. Personal-Nachrichten im Thread werden ignoriert (Web-Oberfläche bleibt die Quelle). */
/** Nachricht im Ticket-Thread bzw. Ticket-Kanal übernehmen: vom Kunden als Kundenantwort, vom Personal (verknüpftes Konto)
* als Support-Antwort wie im Kundencenter (Status "wartet auf Kunde", Mail an den Ersteller). Andere Nachrichten werden ignoriert. */
async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise<void> {
if (msg.author.bot || !msg.channel.isThread()) return;
const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id, tk.status FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [msg.channel.id]).then(([r]: any) => r[0]);
if (msg.author.bot || !msg.inGuild()) return;
const t = await pool.query('SELECT tt.ticket_id, tk.number, tk.subject, tk.org_id, tk.status, tk.created_by FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tt.thread_id = ?', [msg.channel.id]).then(([r]: any) => r[0]);
if (!t) return;
if (t.status === 'closed') { await msg.reply('Dieses Ticket ist geschlossen. Bitte im Kundencenter ein neues Ticket eröffnen oder den Befehl `/ticket` nutzen.').catch(() => undefined); return; }
const u = await pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = 'customer'", [msg.author.id]).then(([r]: any) => r[0]);
if (!u) return; // unbekannt oder Personal: Web-Oberfläche bleibt die Quelle für Personal-Antworten
const member = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [u.id, t.org_id]).then(([r]: any) => r[0]);
if (!member) return;
const body = msg.content.trim().slice(0, 10000);
if (!body) return;
const staff = await linkedUser(msg.author.id, 'staff');
if (staff) {
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.ticket_id, staff.id, 'staff', body]);
await pool.query("UPDATE tickets SET status = 'pending_customer', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.ticket_id]);
await audit({ actorType: 'user', actorId: staff.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: t.ticket_id, after: { internalNote: false, via: 'discord' } });
const creator = await pool.query('SELECT email, name FROM users WHERE id = ?', [t.created_by]).then(([r]: any) => r[0]);
if (creator) await enqueue('mail.template', { to: creator.email, key: 'ticket_message', vars: { name: creator.name, number: t.number, subject: t.subject, ticketLink: ticketLink(t.ticket_id) } }, { idempotencyKey: `mail:discord-staff:${msg.id}` });
log(`Discord: Support-Antwort zu Ticket ${t.number} übernommen.`);
return;
}
const u = await pool.query("SELECT id, name FROM users WHERE discord_user_id = ? AND kind = 'customer'", [msg.author.id]).then(([r]: any) => r[0]);
if (!u) return; // Discord-Konto ohne Verknüpfung: nicht zuordenbar
const member = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [u.id, t.org_id]).then(([r]: any) => r[0]);
if (!member) return;
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.ticket_id, u.id, 'customer', body]);
await pool.query("UPDATE tickets SET status = 'pending_staff', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.ticket_id]);
await notify('ticket.message', { number: t.number }, `discord-in:${msg.id}`);
log(`Discord: Antwort im Thread zu Ticket ${t.number} übernommen.`);
}
/** Stellt sicher, dass ein privater Thread für das Ticket existiert (nur wenn der Kunde Discord verknüpft hat), und postet die Nachricht hinein. */
// ---- Kategorie-Modus: je offenem Ticket ein eigener Kanal ------------------------------------------------------------
// Sichtbar nur für die Support-Rollen, den Bot und die per Discord verknüpften Mitglieder des Kunden (@everyone: nein).
// Die Kategorie zeigt damit genau die offenen Tickets; beim Schließen wird der Kanal gelöscht (Verlauf bleibt im Kundencenter).
const CHANNEL_PERMS = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.AttachFiles];
const BOT_PERMS = [...CHANNEL_PERMS, PermissionFlagsBits.ManageChannels];
const slug = (v: string) => v.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
const ticketLink = (id: string) => `${config.baseUrl}/tickets/${id}`;
/** Discord-Konten der Kunden-Mitglieder (nur verknüpfte, aktive Benutzer der Organisation). */
const orgDiscordIds = (orgId: string) => pool.query("SELECT u.discord_user_id FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? AND u.kind = 'customer' AND u.status = 'active' AND u.discord_user_id IS NOT NULL", [orgId])
.then(([r]: any) => (r as { discord_user_id: string }[]).map((x) => x.discord_user_id));
function overwrites(guild: Guild, s: DiscordSettings, customerIds: string[]): OverwriteResolvable[] {
return [
{ id: guild.roles.everyone.id, type: OverwriteType.Role, deny: [PermissionFlagsBits.ViewChannel] },
{ id: guild.client.user.id, type: OverwriteType.Member, allow: BOT_PERMS },
...s.supportRoleIds.map((id) => ({ id, type: OverwriteType.Role, allow: CHANNEL_PERMS })),
...customerIds.map((id) => ({ id, type: OverwriteType.Member, allow: CHANNEL_PERMS })),
];
}
/** Seriell je Ticket, damit Abgleich und Auftrag nicht gleichzeitig zwei Kanäle für dasselbe Ticket anlegen. */
const locks = new Map<string, Promise<unknown>>();
function serial<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.catch(() => undefined).then(fn);
locks.set(key, next);
void next.finally(() => { if (locks.get(key) === next) locks.delete(key); }).catch(() => undefined);
return next;
}
async function fetchChannel(id: string): Promise<TextChannel | null> {
const ch = await client!.channels.fetch(id).catch((e) => { if ((e as { code?: number }).code === 10003) return null; throw e; }); // 10003 = Unknown Channel
return ch && ch.type === ChannelType.GuildText ? ch : null;
}
/** Liefert den Kanal des Tickets und legt ihn bei Bedarf an (mit Kopfzeile und, falls gewünscht, der Eröffnungsnachricht). */
async function ensureTicketChannel(s: DiscordSettings, ticketId: string, withOpening: boolean): Promise<{ channel: TextChannel; created: boolean } | null> {
return serial(ticketId, async () => {
const t = await pool.query('SELECT tk.id, tk.number, tk.subject, tk.status, tk.org_id, o.name AS org_name, o.customer_number FROM tickets tk JOIN organizations o ON o.id = tk.org_id WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]);
if (!t || t.status === 'closed') return null;
const known = await pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined);
if (known) {
const ch = await fetchChannel(known);
if (ch) return { channel: ch, created: false };
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]); // in Discord gelöscht: neu anlegen
}
const category = await client!.channels.fetch(s.ticketCategoryId!);
if (!category || category.type !== ChannelType.GuildCategory) throw new Error('Ticket-Kategorie nicht gefunden oder keine Kategorie');
const customerIds = await orgDiscordIds(t.org_id);
const channel = await category.guild.channels.create({
name: `${slug(t.number)}-${slug(t.org_name)}`.slice(0, 100), type: ChannelType.GuildText, parent: category.id,
topic: `${t.number} · ${t.subject}`.slice(0, 1024), permissionOverwrites: overwrites(category.guild, s, customerIds), reason: `Ticket ${t.number}`,
});
await pool.query('INSERT INTO ticket_discord_threads (ticket_id, thread_id) VALUES (?,?)', [ticketId, channel.id]);
await channel.send({ content: `**${t.number} · ${t.subject}**\nKunde: ${t.org_name} (${t.customer_number})\nIm Kundencenter: ${ticketLink(t.id)}\n_Nachrichten hier werden ins Ticket übernommen. Antworten des Supports gehen dem Kunden zusätzlich per Mail zu._`.slice(0, 2000), allowedMentions: { parse: [] } });
if (withOpening) {
const first = await pool.query('SELECT m.body, m.author_kind FROM ticket_messages m WHERE m.ticket_id = ? AND m.internal_note = 0 ORDER BY m.created_at LIMIT 1', [ticketId]).then(([r]: any) => r[0]);
if (first) await channel.send({ content: `**${first.author_kind === 'staff' ? 'Support' : 'Kunde'}:** ${first.body}`.slice(0, 2000), allowedMentions: { parse: [] } });
}
return { channel, created: true };
});
}
/** Ergänzt Kunden-Mitglieder, die ihr Discord erst nach dem Anlegen des Kanals verknüpft haben (nur wenn etwas fehlt). */
async function grantCustomers(channel: TextChannel, orgId: string): Promise<void> {
for (const id of await orgDiscordIds(orgId)) {
if (!channel.permissionOverwrites.cache.has(id)) await channel.permissionOverwrites.create(id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, AttachFiles: true }, { type: OverwriteType.Member, reason: 'Kunde hat Discord verknüpft' });
}
}
/** Abgleich (minütlich): Kanäle für offene Tickets anlegen, Kanäle geschlossener Tickets löschen, Support-Meldekanal anlegen. */
export async function reconcileTicketChannels(log: (m: string) => void): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (!s.ticketCategoryId) return;
if (!s.ticketLogChannelId) {
const category = await client.channels.fetch(s.ticketCategoryId);
if (category?.type === ChannelType.GuildCategory) {
const ch = await category.guild.channels.create({ name: 'ticket-log', type: ChannelType.GuildText, parent: category.id, topic: 'Meldungen zu neuen Tickets und Antworten (nur Support)', permissionOverwrites: overwrites(category.guild, s, []), reason: 'Ticket-Meldungen nur für den Support' });
await pool.query('UPDATE discord_settings SET ticket_log_channel_id = ? WHERE id = 1', [ch.id]);
log(`Discord: Support-Kanal #ticket-log angelegt (${ch.id}).`);
}
}
// Geschlossene Tickets: Kanal entfernen
const [closed] = await pool.query("SELECT tt.ticket_id, tt.thread_id, tk.number FROM ticket_discord_threads tt JOIN tickets tk ON tk.id = tt.ticket_id WHERE tk.status = 'closed'") as any;
for (const r of closed) {
await serial(r.ticket_id, async () => {
const ch = await fetchChannel(r.thread_id);
if (ch) await ch.delete(`Ticket ${r.number} geschlossen`);
await pool.query('DELETE FROM ticket_discord_threads WHERE ticket_id = ?', [r.ticket_id]);
});
log(`Discord: Kanal zu Ticket ${r.number} entfernt (geschlossen).`);
}
// Offene Tickets ohne Kanal (z. B. aus Mail-Eingang oder Vertragsverlängerung). Erst nach 2 Minuten, damit der
// Auftrag zur Eröffnungsnachricht zuerst greift und die Nachricht nicht doppelt erscheint.
const [open] = await pool.query("SELECT tk.id, tk.org_id, tt.thread_id FROM tickets tk LEFT JOIN ticket_discord_threads tt ON tt.ticket_id = tk.id WHERE tk.status <> 'closed' AND (tt.thread_id IS NOT NULL OR tk.created_at < DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 MINUTE))") as any;
for (const r of open) {
const res = await ensureTicketChannel(s, r.id, true);
if (res && !res.created) await grantCustomers(res.channel, r.org_id);
}
}
/** Nachricht ins Ticket in Discord übernehmen. Kategorie-Modus: Kanal je Ticket (legt ihn bei Bedarf an).
* Sonst (Altmodus): privater Thread im Ticket-Kanal, nur wenn der Ersteller Discord verknüpft hat. */
export async function postTicketMessage(ticketId: string, body: string, authorLabel: string): Promise<void> {
if (!client?.isReady()) return;
const s = await loadSettings();
if (s.ticketCategoryId) {
const res = await ensureTicketChannel(s, ticketId, false);
if (!res) return; // Ticket geschlossen oder unbekannt
const t = await pool.query('SELECT org_id FROM tickets WHERE id = ?', [ticketId]).then(([r]: any) => r[0]);
if (t && !res.created) await grantCustomers(res.channel, t.org_id);
await res.channel.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000), allowedMentions: { parse: [] } });
return;
}
if (!s.ticketChannelId) return;
const t = await pool.query('SELECT tk.number, tk.subject, u.discord_user_id FROM tickets tk JOIN users u ON u.id = tk.created_by WHERE tk.id = ?', [ticketId]).then(([r]: any) => r[0]);
if (!t?.discord_user_id) return; // Kunde hat kein Discord verknüpft
@ -160,6 +290,8 @@ export async function postTicketMessage(ticketId: string, body: string, authorLa
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Ticket-Thread nicht gefunden');
await ch.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000) });
}
/** Kanal-ID des Tickets in Discord (Thread oder Kanal), falls vorhanden. */
export const ticketChannelId = (ticketId: string) => pool.query('SELECT thread_id FROM ticket_discord_threads WHERE ticket_id = ?', [ticketId]).then(([r]: any) => r[0]?.thread_id as string | undefined);
async function connect(s: DiscordSettings, log: (m: string) => void): Promise<void> {
client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
@ -182,8 +314,8 @@ async function connect(s: DiscordSettings, log: (m: string) => void): Promise<vo
/** Prüft, ob sich Token/Server/Aktivierung geändert haben, und verbindet bei Bedarf neu. Für periodischen Aufruf gedacht. */
export async function syncDiscord(log: (m: string) => void): Promise<void> {
const s = await loadSettings();
cachedEnabled = s.enabled && !!s.token;
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
cachedEnabled = s.enabled && !!s.token && (await hasFeature('discord')); // Discord-Bot nur mit Lizenz
const fp = `${s.enabled}|${s.token}|${s.guildId}|${cachedEnabled}`; // Lizenzwechsel verbindet/trennt ebenfalls
if (fp === lastFingerprint) return;
lastFingerprint = fp;
if (client) { await client.destroy().catch(() => undefined); client = null; }
@ -196,13 +328,14 @@ export async function syncDiscord(log: (m: string) => void): Promise<void> {
}
}
/** Sendet eine Nachricht in den Admin-Kanal. Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string): Promise<'sent' | 'skipped'> {
/** Sendet eine Nachricht in den Admin-Kanal (Ticket-Meldungen in den Support-Kanal #ticket-log, falls angelegt). Wirft bei Fehlern, damit der Job wiederholt wird. */
export async function notifyAdmin(text: string, target: 'admin' | 'tickets' = 'admin'): Promise<'sent' | 'skipped'> {
if (!client?.isReady()) return 'skipped';
const s = await loadSettings();
if (!s.adminChannelId) return 'skipped';
const ch = await client.channels.fetch(s.adminChannelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Admin-Kanal nicht gefunden oder kein Textkanal');
const channelId = target === 'tickets' && s.ticketLogChannelId ? s.ticketLogChannelId : s.adminChannelId;
if (!channelId) return 'skipped';
const ch = await client.channels.fetch(channelId);
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Meldekanal nicht gefunden oder kein Textkanal');
await ch.send({ content: text, allowedMentions: { parse: [] } });
return 'sent';
}

View file

@ -1,7 +1,7 @@
import http from 'node:http';
import { env } from './env.js';
import { pool } from '@kc/platform/db';
import { discordEnabled, discordReady, syncDiscord, stopDiscord } from './discord.js';
import { discordEnabled, discordReady, reconcileTicketChannels, syncDiscord, stopDiscord } from './discord.js';
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
@ -24,6 +24,8 @@ await recoverStale(log);
void syncDiscord(log);
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
// Discord-Ticketkanäle: offene Tickets bekommen einen Kanal, geschlossene verlieren ihn (nur im Kategorie-Modus)
setInterval(() => void reconcileTicketChannels(log).catch((e) => log(`Ticket-Kanäle: Abgleich fehlgeschlagen: ${(e as Error).message}`)), 60_000);
// Regelmäßiger Abgleich: fällige Connector-Instanzen als Aufträge einplanen (idempotent pro Zeitfenster)
const schedule = () => scheduleDueSyncs((t, p, o) => enqueue(t, p, o)).catch((e) => log(`Planung fehlgeschlagen: ${(e as Error).message}`));
setInterval(schedule, 30_000); void schedule();

View file

@ -23,7 +23,7 @@ const handlers: Record<string, Handler> = {
: p.event === 'ticket.message' ? `🎫 Neue Nachricht zu Ticket ${p.number}`
: p.event === 'invoice.issued' ? `🧾 Rechnung ${p.number} ausgestellt (${(Number(p.gross ?? 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })})` : null; // keine personenbezogenen Daten
if (!text) throw new Error(`Unbekanntes Ereignis: ${p.event}`);
const r = await notifyAdmin(text);
const r = await notifyAdmin(text, String(p.event).startsWith('ticket.') ? 'tickets' : 'admin');
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
},
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {

View file

@ -4,6 +4,7 @@ import { simpleParser } from 'mailparser';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
import { hasFeature } from '@kc/platform/license';
/** Sehr einfache HTML->Text-Reduktion für Mails ohne Text-Teil. Nur zur Anzeige, nie als HTML gerendert. */
function htmlToText(html: string): string {
@ -27,6 +28,7 @@ export async function checkMailbox(log: (m: string) => void): Promise<void> {
const [rows] = await pool.query('SELECT * FROM imap_settings WHERE id = 1') as any;
const s = rows[0];
if (!s?.enabled || !s?.host || !s?.username || !s?.secrets_enc) return;
if (!(await hasFeature('imap'))) return; // E-Mail-Posteingang nur mit Lizenz
const password = JSON.parse(decrypt(s.secrets_enc)).password as string;
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
try {

View file

@ -0,0 +1,7 @@
-- Discord: je offenem Ticket ein eigener Kanal in einer Kategorie (statt privater Threads), sichtbar nur für die
-- Support-Rollen und die verknüpften Mitglieder des Kunden. Ticket-Meldungen gehen in einen eigenen Support-Kanal.
-- ticket_discord_threads.thread_id enthält im Kategorie-Modus die Kanal-ID.
ALTER TABLE discord_settings
ADD COLUMN ticket_category_id VARCHAR(32) NULL,
ADD COLUMN ticket_log_channel_id VARCHAR(32) NULL,
ADD COLUMN support_role_ids VARCHAR(500) NULL;

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter API
After=network-online.target mariadb.service
Wants=network-online.target mariadb.service
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=-/etc/kundencenter/backup.env
EnvironmentFile=/etc/kundencenter/app.env
ExecStart=/usr/bin/node dist/server.js
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/kundencenter/requests /var/lib/kundencenter/ticket-attachments
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,7 @@
[Unit]
Description=Kundencenter: Backup-Anfragen aus der Oberflaeche
[Path]
PathExistsGlob=/var/lib/kundencenter/requests/backup-*
Unit=kc-backup-request.service
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter: Backup-Anfrage aus der Oberflaeche abarbeiten
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=NODE_BIN=/usr/bin/node
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/bin/bash /srv/kundencenter/ops/process-requests.sh
TimeoutStartSec=1h

View file

@ -0,0 +1,20 @@
[Unit]
Description=Kundencenter-Backup
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/usr/bin/node dist/cli/index.js backup run
TimeoutStartSec=1h

View file

@ -0,0 +1,8 @@
[Unit]
Description=Taegliches Kundencenter-Backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,20 @@
[Unit]
Description=Kundencenter Wiederherstellungstest
After=mariadb.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/srv/kundencenter/apps/api
Environment=KC_ENV_DIR=/nonexistent
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/backup.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/backups/kundencenter /var/lib/kundencenter
ExecStart=/usr/bin/node dist/cli/index.js backup restore-test
TimeoutStartSec=1h

View file

@ -0,0 +1,8 @@
[Unit]
Description=Woechentlicher Wiederherstellungstest
[Timer]
OnCalendar=Sun *-*-* 04:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,19 @@
[Unit]
Description=Kundencenter Web
After=network-online.target kc-api.service
Wants=network-online.target
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/web
Environment=NODE_ENV=production
Environment=NEXT_TELEMETRY_DISABLED=1
ExecStart=/usr/bin/node node_modules/next/dist/bin/next start -p 4101 -H 127.0.0.1
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/kundencenter/apps/web/.next
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,21 @@
[Unit]
Description=Kundencenter Worker (Jobs, Discord-Bot)
After=network-online.target mariadb.service kc-api.service
Wants=network-online.target mariadb.service
[Service]
User=kundencenter
WorkingDirectory=/srv/kundencenter/apps/worker
Environment=KC_ENV_DIR=/nonexistent
EnvironmentFile=/etc/kundencenter/db.env
EnvironmentFile=-/etc/kundencenter/backup.env
EnvironmentFile=/etc/kundencenter/app.env
EnvironmentFile=-/etc/kundencenter/discord.env
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View file

@ -1,7 +1,7 @@
{
"name": "kundencenter",
"private": true,
"version": "0.1.0",
"version": "1.0.0",
"packageManager": "pnpm@10.14.0",
"engines": { "node": ">=22" },
"scripts": {

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

View file

@ -127,36 +127,75 @@ export async function getEntitlement(): Promise<Entitlement> {
trial: !!row.is_trial, expiresAt, configured: true, source: withinOfflineGrace ? 'live' : 'offline-grace', checkedAt, message: row.message,
};
}
/** Wie assertCustomerCapacity: solange Lizenzierung nicht konfiguriert/geprüft ist, nichts einschränken. */
// ---- Edition: Testmodus ohne Lizenz, voller Umfang mit Lizenz ---------------------------------------------------
/** Funktionen, die eine Lizenz voraussetzen. Modul-Schlüssel im Lizenzsystem (Produkt/Lizenz "modules"). */
export const LICENSED_FEATURES = ['discord', 'imap', 'datev', 'backup_remote'] as const;
export type LicensedFeature = (typeof LICENSED_FEATURES)[number];
export const FEATURE_LABEL: Record<LicensedFeature, string> = { discord: 'Discord-Bot', imap: 'E-Mail-Posteingang (IMAP)', datev: 'DATEV-Export', backup_remote: 'Backups auf externe Ziele' };
/** Grenzen des Testmodus (ohne gültige Lizenz). Bestehende Daten bleiben unangetastet, nur Neuanlagen werden begrenzt. */
export const TRIAL_LIMITS = { staff: 1, customers: 2 } as const;
export interface Edition {
mode: 'licensed' | 'trial'; features: LicensedFeature[]; customerLimit: number | null; staffLimit: number | null;
/** Warum Testmodus (z. B. kein Schlüssel, abgelaufen); bei Lizenz der Plan. */ reason: string | null;
}
/** Maßgebliche Edition dieser Installation. Gültige Lizenz: deren Grenzen; eine Lizenz ohne Modulliste schaltet alle
* Funktionen frei (unbeschränkter Plan). Ohne gültige Lizenz (kein Schlüssel, ungeprüft, ungültig, abgelaufen oder
* länger als die Offline-Frist nicht geprüft): Testmodus mit TRIAL_LIMITS und ohne LICENSED_FEATURES. */
export async function getEdition(): Promise<Edition> {
// Integrationstests: voller Umfang (wie die abgeschalteten Rate-Limits im Testmodus)
if (config.env === 'test') return { mode: 'licensed', features: [...LICENSED_FEATURES], customerLimit: null, staffLimit: null, reason: 'test' };
const e = await getEntitlement();
if (e.valid) {
const mods = e.modules.filter((m): m is LicensedFeature => (LICENSED_FEATURES as readonly string[]).includes(m));
return { mode: 'licensed', features: e.modules.length ? mods : [...LICENSED_FEATURES], customerLimit: e.customerLimit, staffLimit: e.userLimit, reason: e.plan };
}
const reason = e.source === 'unconfigured' ? 'Kein Lizenzschlüssel hinterlegt' : e.source === 'unchecked' ? 'Lizenz noch nicht geprüft' : (e.message ?? 'Lizenz ungültig oder abgelaufen');
return { mode: 'trial', features: [], customerLimit: TRIAL_LIMITS.customers, staffLimit: TRIAL_LIMITS.staff, reason };
}
export async function hasFeature(f: LicensedFeature): Promise<boolean> { return (await getEdition()).features.includes(f); }
/** Fehlertext für gesperrte Funktionen (einheitlich in API und Worker). */
export const featureRequiresLicense = (f: LicensedFeature) => `${FEATURE_LABEL[f]} ist nur mit Lizenz verfügbar (Einstellungen → Lizenz).`;
/** Modul aus der Lizenz (allgemein, z. B. für künftige Zusatzmodule). */
export async function hasModule(key: string): Promise<boolean> {
const e = await getEntitlement();
if (e.source === 'unconfigured' || e.source === 'unchecked') return true;
return e.valid && e.modules.includes(key);
return e.valid && (e.modules.length === 0 || e.modules.includes(key));
}
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
/** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung
* aufrufen. GET_LOCK serialisiert gegen gleichzeitige Anfragen auf den letzten freien Platz. Zählt aktive
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr.
*
* Solange die Lizenzierung gar nicht konfiguriert ist (kein LICENSE_PROGRAM_KEY/-schlüssel) oder noch nie
* erfolgreich geprüft wurde, wird NICHT durchgesetzt - sonst wäre jede frische Installation (und jede Test-
* umgebung) ab dem ersten Kunden blockiert. Erst eine tatsächlich geprüfte, ungültige/überschrittene Lizenz
* sperrt neue Kunden. */
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. Grenze aus der Edition
* (Lizenz oder Testmodus). */
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
const ent = await getEntitlement();
if (ent.source === 'unconfigured' || ent.source === 'unchecked') return { allowed: true, count: 0, limit: null };
if (!ent.valid) return { allowed: false, reason: ent.message ?? 'Keine gültige Lizenz', count: 0, limit: null };
const ed = await getEdition();
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
try {
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
const count = Number(row?.n ?? 0);
if (ent.customerLimit !== null && count >= ent.customerLimit) return { allowed: false, reason: `Kundengrenze erreicht (${count}/${ent.customerLimit}). Bitte Lizenz upgraden.`, count, limit: ent.customerLimit };
return { allowed: true, count, limit: ent.customerLimit };
if (ed.customerLimit !== null && count >= ed.customerLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.customerLimit} Kunden. Für weitere Kunden bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Kundengrenze der Lizenz erreicht (${count}/${ed.customerLimit}). Bitte Lizenz upgraden.`;
return { allowed: false, reason, count, limit: ed.customerLimit };
}
return { allowed: true, count, limit: ed.customerLimit };
} finally {
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
}
}
/** Grenze für Personal-Konten (aktive und eingeladene Mitarbeiter). */
export async function assertStaffCapacity(): Promise<CapacityCheck> {
const ed = await getEdition();
const row = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')");
const count = Number(row?.n ?? 0);
if (ed.staffLimit !== null && count >= ed.staffLimit) {
const reason = ed.mode === 'trial'
? `Testmodus: höchstens ${ed.staffLimit} Personal-Konto. Für weitere Mitarbeiter bitte eine Lizenz hinterlegen (Einstellungen → Lizenz).`
: `Benutzergrenze der Lizenz erreicht (${count}/${ed.staffLimit}).`;
return { allowed: false, reason, count, limit: ed.staffLimit };
}
return { allowed: true, count, limit: ed.staffLimit };
}
export async function setLicenseKey(licenseKey: string): Promise<void> {
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);