Compare commits
5 commits
711b99bb34
...
17267b7c05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17267b7c05 | ||
|
|
fdd51b18e2 | ||
|
|
a322166d01 | ||
|
|
9336f46d0a | ||
|
|
8ef0b3bc05 |
40 changed files with 1342 additions and 129 deletions
30
.env.example
30
.env.example
|
|
@ -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_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=kundencenter
|
DB_NAME=kundencenter
|
||||||
DB_USER=kundencenter
|
DB_USER=kundencenter
|
||||||
DB_PASSWORD=
|
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_API_PORT=4100
|
||||||
KC_NODE_ENV=development
|
KC_NODE_ENV=production
|
||||||
# optional: SMTP (sonst wird nur im Mail-Protokoll abgelegt)
|
# Kennung des Programms "Kundencenter" beim Lizenzsystem (kein Geheimnis, in jeder Installation gleich).
|
||||||
SMTP_HOST=
|
# Den eigenen Lizenzschlüssel trägt man danach in der Oberfläche ein (Einstellungen → Lizenz).
|
||||||
SMTP_PORT=587
|
LICENSE_PROGRAM_KEY=da78e720-7180-4c44-84dd-50bfc83fe1ef
|
||||||
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
|
|
||||||
|
|
|
||||||
18
LICENSE.md
Normal file
18
LICENSE.md
Normal 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
128
README.md
|
|
@ -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 |
|
| Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Policy, Audit |
|
| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Rechte, Audit |
|
||||||
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot |
|
| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot, E-Mail-Posteingang |
|
||||||
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, Proxy `/api/*` → API |
|
| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, leitet `/api/*` an die API weiter |
|
||||||
|
|
||||||
## Stand (Grundsystem)
|
## Funktionen
|
||||||
Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen),
|
- **Konten und Sicherheit:** Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Einladungen, Rollen für Personal und Kunden, Audit-Protokoll mit Hash-Kette.
|
||||||
Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional).
|
- **Kunden:** Privat- und Geschäftskunden, Rechnungsanschrift, Übernahme aus KeyHelp, Kundenansicht („Als Kunde ansehen“).
|
||||||
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`).
|
- **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`).
|
||||||
Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung).
|
- **Bereitstellung über Connectoren:** KeyHelp (Hosting-Konten, Domains, Postfächer, Datenbanken) und Lizenzsystem (`docs/connector-vertrag.md`, `docs/connector-keyhelp.md`).
|
||||||
Rechnungen (`docs/rechnungen.md`): Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF-Erzeugung, Firmenstammdaten unter Einstellungen → Firma.
|
- **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.
|
||||||
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).
|
- **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
|
## Testmodus und Lizenz
|
||||||
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).
|
Ohne Lizenzschlüssel läuft das Kundencenter im **Testmodus**:
|
||||||
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.
|
|
||||||
|
|
||||||
## Konfiguration
|
| | Testmodus | Mit Lizenz |
|
||||||
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).
|
| 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
|
## Betrieb
|
||||||
```bash
|
```bash
|
||||||
systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always)
|
systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always)
|
||||||
journalctl -u kc-api -f # Logs
|
journalctl -u kc-api -f # Logs
|
||||||
pnpm install && pnpm build # Update: danach chown -R kundencenter und systemctl restart kc-*
|
pnpm cli reset-password --email=… # Passwort zurücksetzen
|
||||||
pnpm migrate # Migrationen (migrations/*.sql, einmalig je Datei)
|
|
||||||
pnpm cli create-superadmin --email=… --name=… --password=…
|
|
||||||
```
|
```
|
||||||
Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`.
|
Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`.
|
||||||
|
Geheimnisse liegen nie im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600). `KC_BASE_URL` muss exakt der Browser-Adresse entsprechen.
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Discord-Bot
|
## 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`.
|
Unter **Einstellungen → Discord** Bot-Token, Server-ID und Kanäle eintragen (Anleitung auf der Seite). Mit einer
|
||||||
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“).
|
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)
|
## Modularität
|
||||||
```bash
|
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`). Sie werden in
|
||||||
set -a; . /etc/kundencenter/db.env; set +a
|
`modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
|
||||||
MYSQL_PWD=$DB_PASSWORD mysqldump -h127.0.0.1 -u$DB_USER --single-transaction --routines $DB_NAME | gzip > kundencenter-$(date +%F).sql.gz
|
Neue Anbieter kommen als Connector unter `packages/connector-*` hinzu (`docs/connector-vertrag.md`).
|
||||||
gunzip -c kundencenter-DATUM.sql.gz | MYSQL_PWD=$DB_PASSWORD mysql -h127.0.0.1 -u$DB_USER $DB_NAME # Restore in leere DB
|
|
||||||
```
|
## Tests
|
||||||
Automatisiertes, verschlüsseltes Backup mit Restore-Test ist noch offen (siehe Plane).
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { hash } from '@node-rs/argon2';
|
||||||
import '../core/config.js';
|
import '../core/config.js';
|
||||||
import { pool, one, run } from '../core/db.js';
|
import { pool, one, run } from '../core/db.js';
|
||||||
import { audit } from '../core/audit.js';
|
import { audit } from '../core/audit.js';
|
||||||
|
import { assertStaffCapacity } from '../core/license.js';
|
||||||
|
|
||||||
const [cmd, ...args] = process.argv.slice(2);
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
|
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';
|
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 (!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); }
|
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); });
|
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); }
|
if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); }
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '
|
||||||
import { loadTargets, testTarget } from '../../ops/targets.js';
|
import { loadTargets, testTarget } from '../../ops/targets.js';
|
||||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
|
||||||
|
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
|
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) => {
|
app.post('/admin/backup/targets', async (req) => {
|
||||||
const a = requirePermission(req, 'backup.secrets');
|
const a = requirePermission(req, 'backup.secrets');
|
||||||
|
if (!(await hasFeature('backup_remote'))) throw forbidden(featureRequiresLicense('backup_remote'), 'LICENSE_REQUIRED');
|
||||||
const b = targetSchema.parse(req.body);
|
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');
|
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();
|
const { cfg, sec, path } = split(b); const id = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js';
|
||||||
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
|
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
|
||||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest } from '../../core/errors.js';
|
import { badRequest } from '../../core/errors.js';
|
||||||
|
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||||
import { rl, config } from '../../core/config.js';
|
import { rl, config } from '../../core/config.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs
|
||||||
const settingsView = (s: any) => ({
|
const settingsView = (s: any) => ({
|
||||||
enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id,
|
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,
|
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,
|
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`;
|
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
|
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(),
|
guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(),
|
||||||
ticketChannelId: 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),
|
enabled: z.boolean().default(false),
|
||||||
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
|
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
|
||||||
}).parse(req.body);
|
}).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 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];
|
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.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); }
|
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);
|
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. */
|
/** 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) => {
|
app.get('/discord/oauth/start', async (req, reply) => {
|
||||||
const a = requireAuth(req);
|
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');
|
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');
|
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
|
||||||
const state = randomToken(32);
|
const state = randomToken(32);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { one, query, run, tx } from '../../core/db.js';
|
||||||
import { audit } from '../../core/audit.js';
|
import { audit } from '../../core/audit.js';
|
||||||
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
|
import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.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 { decrypt, encrypt, sha256 } from '../../core/crypto.js';
|
||||||
import { can, staffPermissions } from '../../core/policy.js';
|
import { can, staffPermissions } from '../../core/policy.js';
|
||||||
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.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);
|
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 ((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');
|
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 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);
|
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 } });
|
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 } });
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { backupModule } from './backup/index.js';
|
||||||
import { mailModule } from './mail/index.js';
|
import { mailModule } from './mail/index.js';
|
||||||
import { discordModule } from './discord/index.js';
|
import { discordModule } from './discord/index.js';
|
||||||
import { licenseModule } from './license/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. */
|
/** 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];
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { audit } from '../../core/audit.js';
|
||||||
import { enqueue } from '../../core/jobs.js';
|
import { enqueue } from '../../core/jobs.js';
|
||||||
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.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 { can, canInOrg } from '../../core/policy.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
import { config } from '../../core/config.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). */
|
* 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) => {
|
app.get('/admin/invoices/export.datev', async (req, reply) => {
|
||||||
const a = requirePermission(req, 'invoices.read');
|
const a = requirePermission(req, 'invoices.read');
|
||||||
|
if (!(await hasFeature('datev'))) throw forbidden(featureRequiresLicense('datev'), 'LICENSE_REQUIRED');
|
||||||
const q = exportQuery.parse(req.query);
|
const q = exportQuery.parse(req.query);
|
||||||
const d = await datevSettings();
|
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');
|
if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE');
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { one } from '../../core/db.js';
|
||||||
import { audit } from '../../core/audit.js';
|
import { audit } from '../../core/audit.js';
|
||||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest } from '../../core/errors.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';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit
|
/** 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 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 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')");
|
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) => {
|
app.put('/admin/license/settings', async (req) => {
|
||||||
|
|
|
||||||
266
apps/api/src/modules/licensing/index.ts
Normal file
266
apps/api/src/modules/licensing/index.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ import { audit } from '../../core/audit.js';
|
||||||
import { encrypt, decrypt } from '../../core/crypto.js';
|
import { encrypt, decrypt } from '../../core/crypto.js';
|
||||||
import { clientIp, requirePermission } from '../../core/auth.js';
|
import { clientIp, requirePermission } from '../../core/auth.js';
|
||||||
import { badRequest, notFound } from '../../core/errors.js';
|
import { badRequest, notFound } from '../../core/errors.js';
|
||||||
|
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
|
||||||
import { rl } from '../../core/config.js';
|
import { rl } from '../../core/config.js';
|
||||||
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
|
import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js';
|
||||||
import type { KcModule } from '../../core/module.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(),
|
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),
|
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);
|
}).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 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];
|
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); }
|
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }
|
||||||
|
|
|
||||||
|
|
@ -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 { fileName, parseName, selectDeletions, type Keep } from './retention.js';
|
||||||
import { getBackupPassword } from './settings.js';
|
import { getBackupPassword } from './settings.js';
|
||||||
import { buildRemote, loadTargets, friendly, type Remote } from './targets.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). */
|
/** 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 }
|
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 });
|
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
|
||||||
targets.push({ name: `lokal (${c.dir})`, ok: true });
|
targets.push({ name: `lokal (${c.dir})`, ok: true });
|
||||||
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
|
// 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) }); }
|
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 = [];
|
const okDests: typeof dests = [];
|
||||||
for (const d of dests) {
|
for (const d of dests) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; b
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { me, can } = useSession();
|
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 [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('');
|
const [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (me?.kind === 'customer') {
|
if (me?.kind === 'customer') {
|
||||||
|
|
@ -18,6 +19,7 @@ export default function Dashboard() {
|
||||||
if (!me || me.kind !== 'staff') return;
|
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('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('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]);
|
}, [me, can]);
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
|
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
|
||||||
|
|
@ -37,6 +39,7 @@ export default function Dashboard() {
|
||||||
</>) : (<>
|
</>) : (<>
|
||||||
<div className="grid">
|
<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>}
|
{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 && <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 && <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></>
|
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { Alert, Field, fmt } from '@/components/ui';
|
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. */
|
/** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */
|
||||||
function Guide({ redirectUri }: { redirectUri: string }) {
|
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 }}>
|
<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>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-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>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>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>
|
<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 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') ?? '');
|
const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? '');
|
||||||
try {
|
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();
|
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); }
|
} 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="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="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="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>
|
</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>
|
<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>
|
<p className="small muted">Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}</p>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface Status {
|
||||||
trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured';
|
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;
|
checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null;
|
||||||
customerCount: number;
|
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 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' };
|
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>
|
<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>
|
<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>}
|
{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' && <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.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 && <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.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>}
|
{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">
|
<div className="card">
|
||||||
<h3>Status</h3>
|
<h3>Status</h3>
|
||||||
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
|
<dl className="row" style={{ flexWrap: 'wrap', gap: '8px 32px' }}>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
|
||||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
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 { 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;
|
const enrollNeeded = !!me?.mfaEnrollRequired;
|
||||||
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
|
useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]);
|
||||||
useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, 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 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}`); }
|
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 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 = (
|
const nav = (
|
||||||
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
|
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
|
||||||
|
|
@ -53,7 +55,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
)}
|
)}
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<div className="topbar"><strong>Kundencenter</strong><ThemeToggle compact /></div>
|
<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)">
|
{!enrollNeeded && <nav className="bottomnav" aria-label="Hauptnavigation (mobil)">
|
||||||
{bottomLink('/dashboard', 'Übersicht')}
|
{bottomLink('/dashboard', 'Übersicht')}
|
||||||
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}
|
{(me.kind === 'customer' || can('resources.read')) && bottomLink('/ressourcen', 'Ressourcen')}
|
||||||
|
|
|
||||||
180
apps/web/src/app/(app)/lizenzen/[id]/page.tsx
Normal file
180
apps/web/src/app/(app)/lizenzen/[id]/page.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
107
apps/web/src/app/(app)/lizenzen/neu/page.tsx
Normal file
107
apps/web/src/app/(app)/lizenzen/neu/page.tsx
Normal 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>; }
|
||||||
68
apps/web/src/app/(app)/lizenzen/page.tsx
Normal file
68
apps/web/src/app/(app)/lizenzen/page.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
16
apps/web/src/components/Licenses.tsx
Normal file
16
apps/web/src/components/Licenses.tsx
Normal 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></>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
@ -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 { randomUUID } from 'node:crypto';
|
||||||
import { pool } from '@kc/platform/db';
|
import { pool } from '@kc/platform/db';
|
||||||
import { decrypt } from '@kc/platform/crypto';
|
import { decrypt } from '@kc/platform/crypto';
|
||||||
import { enqueue } from '@kc/platform/jobs';
|
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> {
|
async function loadSettings(): Promise<DiscordSettings> {
|
||||||
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
|
const [rows] = await pool.query('SELECT * FROM discord_settings WHERE id = 1') as any;
|
||||||
const s = rows[0];
|
const s = rows[0];
|
||||||
const token = s?.token_enc ? (JSON.parse(decrypt(s.token_enc)).token as string) : null;
|
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 });
|
const notify = (event: string, extra: Record<string, unknown>, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key });
|
||||||
async function nextTicketNumber(): Promise<string> {
|
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 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 notify('ticket.created', { number, subject: subject.slice(0, 100) }, `discord-cmd:${ticketId}`);
|
||||||
await postTicketMessage(ticketId, body, 'Kunde');
|
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;
|
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.
|
// 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 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 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. */
|
/** Weist das Ticket des aktuellen Threads einer per Discord verknüpften Personal-Person zu. */
|
||||||
async function handleAssign(i: ChatInputCommandInteraction): Promise<void> {
|
async function handleAssign(i: ChatInputCommandInteraction): Promise<void> {
|
||||||
|
|
@ -100,7 +114,8 @@ async function handleClose(i: ChatInputCommandInteraction): Promise<void> {
|
||||||
const t = await ticketForThread(i.channelId);
|
const t = await ticketForThread(i.channelId);
|
||||||
if (!t) { await i.reply({ content: 'Dieser Befehl funktioniert nur innerhalb eines Ticket-Threads.', ephemeral: true }); return; }
|
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 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> {
|
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> {
|
async function handleThreadMessage(msg: Message, log: (m: string) => void): Promise<void> {
|
||||||
if (msg.author.bot || !msg.channel.isThread()) return;
|
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 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]);
|
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) 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; }
|
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);
|
const body = msg.content.trim().slice(0, 10000);
|
||||||
if (!body) return;
|
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('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 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}`);
|
await notify('ticket.message', { number: t.number }, `discord-in:${msg.id}`);
|
||||||
log(`Discord: Antwort im Thread zu Ticket ${t.number} übernommen.`);
|
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> {
|
export async function postTicketMessage(ticketId: string, body: string, authorLabel: string): Promise<void> {
|
||||||
if (!client?.isReady()) return;
|
if (!client?.isReady()) return;
|
||||||
const s = await loadSettings();
|
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;
|
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]);
|
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
|
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');
|
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Ticket-Thread nicht gefunden');
|
||||||
await ch.send({ content: `**${authorLabel}:** ${body}`.slice(0, 2000) });
|
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> {
|
async function connect(s: DiscordSettings, log: (m: string) => void): Promise<void> {
|
||||||
client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });
|
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. */
|
/** 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> {
|
export async function syncDiscord(log: (m: string) => void): Promise<void> {
|
||||||
const s = await loadSettings();
|
const s = await loadSettings();
|
||||||
cachedEnabled = s.enabled && !!s.token;
|
cachedEnabled = s.enabled && !!s.token && (await hasFeature('discord')); // Discord-Bot nur mit Lizenz
|
||||||
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
|
const fp = `${s.enabled}|${s.token}|${s.guildId}|${cachedEnabled}`; // Lizenzwechsel verbindet/trennt ebenfalls
|
||||||
if (fp === lastFingerprint) return;
|
if (fp === lastFingerprint) return;
|
||||||
lastFingerprint = fp;
|
lastFingerprint = fp;
|
||||||
if (client) { await client.destroy().catch(() => undefined); client = null; }
|
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. */
|
/** 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): Promise<'sent' | 'skipped'> {
|
export async function notifyAdmin(text: string, target: 'admin' | 'tickets' = 'admin'): Promise<'sent' | 'skipped'> {
|
||||||
if (!client?.isReady()) return 'skipped';
|
if (!client?.isReady()) return 'skipped';
|
||||||
const s = await loadSettings();
|
const s = await loadSettings();
|
||||||
if (!s.adminChannelId) return 'skipped';
|
const channelId = target === 'tickets' && s.ticketLogChannelId ? s.ticketLogChannelId : s.adminChannelId;
|
||||||
const ch = await client.channels.fetch(s.adminChannelId);
|
if (!channelId) return 'skipped';
|
||||||
if (!ch || !ch.isTextBased() || !('send' in ch)) throw new Error('Admin-Kanal nicht gefunden oder kein Textkanal');
|
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: [] } });
|
await ch.send({ content: text, allowedMentions: { parse: [] } });
|
||||||
return 'sent';
|
return 'sent';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { pool } from '@kc/platform/db';
|
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 { recoverStale, runOnce } from './jobs.js';
|
||||||
import { enqueue } from '@kc/platform/jobs';
|
import { enqueue } from '@kc/platform/jobs';
|
||||||
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
|
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
|
||||||
|
|
@ -24,6 +24,8 @@ await recoverStale(log);
|
||||||
void syncDiscord(log);
|
void syncDiscord(log);
|
||||||
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
|
setInterval(() => void syncDiscord(log), 60_000); // erkennt geänderte Einstellungen (Einstellungen > Discord) und verbindet bei Bedarf neu
|
||||||
setInterval(() => recoverStale(log).catch(() => undefined), 60_000);
|
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)
|
// 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}`));
|
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();
|
setInterval(schedule, 30_000); void schedule();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const handlers: Record<string, Handler> = {
|
||||||
: p.event === 'ticket.message' ? `🎫 Neue Nachricht zu Ticket ${p.number}`
|
: 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
|
: 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}`);
|
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';
|
if (r === 'skipped') return 'übersprungen: Discord nicht konfiguriert';
|
||||||
},
|
},
|
||||||
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {
|
'mail.template': async (p: { to: string; key: string; vars: Record<string, string> }) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { simpleParser } from 'mailparser';
|
||||||
import { pool } from '@kc/platform/db';
|
import { pool } from '@kc/platform/db';
|
||||||
import { decrypt } from '@kc/platform/crypto';
|
import { decrypt } from '@kc/platform/crypto';
|
||||||
import { enqueue } from '@kc/platform/jobs';
|
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. */
|
/** Sehr einfache HTML->Text-Reduktion für Mails ohne Text-Teil. Nur zur Anzeige, nie als HTML gerendert. */
|
||||||
function htmlToText(html: string): string {
|
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 [rows] = await pool.query('SELECT * FROM imap_settings WHERE id = 1') as any;
|
||||||
const s = rows[0];
|
const s = rows[0];
|
||||||
if (!s?.enabled || !s?.host || !s?.username || !s?.secrets_enc) return;
|
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 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 });
|
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
7
migrations/038_discord_ticket_channels.sql
Normal file
7
migrations/038_discord_ticket_channels.sql
Normal 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;
|
||||||
21
ops/systemd/kc-api.service
Normal file
21
ops/systemd/kc-api.service
Normal 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
|
||||||
7
ops/systemd/kc-backup-request.path
Normal file
7
ops/systemd/kc-backup-request.path
Normal 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
|
||||||
21
ops/systemd/kc-backup-request.service
Normal file
21
ops/systemd/kc-backup-request.service
Normal 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
|
||||||
20
ops/systemd/kc-backup.service
Normal file
20
ops/systemd/kc-backup.service
Normal 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
|
||||||
8
ops/systemd/kc-backup.timer
Normal file
8
ops/systemd/kc-backup.timer
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Taegliches Kundencenter-Backup
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:30:00
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
20
ops/systemd/kc-restore-test.service
Normal file
20
ops/systemd/kc-restore-test.service
Normal 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
|
||||||
8
ops/systemd/kc-restore-test.timer
Normal file
8
ops/systemd/kc-restore-test.timer
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Woechentlicher Wiederherstellungstest
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun *-*-* 04:30:00
|
||||||
|
RandomizedDelaySec=10min
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
19
ops/systemd/kc-web.service
Normal file
19
ops/systemd/kc-web.service
Normal 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
|
||||||
21
ops/systemd/kc-worker.service
Normal file
21
ops/systemd/kc-worker.service
Normal 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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "kundencenter",
|
"name": "kundencenter",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"packageManager": "pnpm@10.14.0",
|
"packageManager": "pnpm@10.14.0",
|
||||||
"engines": { "node": ">=22" },
|
"engines": { "node": ">=22" },
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
110
packages/connector-licensing/src/admin.ts
Normal file
110
packages/connector-licensing/src/admin.ts
Normal 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>;
|
||||||
|
|
@ -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';
|
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. */
|
/** 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 }
|
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 }
|
||||||
interface RawLicense {
|
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;
|
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;
|
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 } | 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 }
|
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 }
|
||||||
interface RawGroup { id: number; name: string; program_id: number; is_active?: boolean; products?: RawProduct[] }
|
export 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 RawProgram { id: number; name: string; description?: string | null }
|
||||||
|
|
||||||
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
|
/** Das Lizenzsystem speichert naive Ortszeit (datetime.now()); wir wandeln sie in UTC um. */
|
||||||
export function localToUtcIso(naive: string | null | undefined, tz: string): string | null {
|
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. */
|
/** 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'; };
|
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 }
|
export interface LicensingDeps extends HttpOptions { now?: () => Date }
|
||||||
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
|
// Token-Cache hält das Promise, damit parallele Anfragen nur einen Login auslösen
|
||||||
const PREFIXES = ['PREMIUM', 'TRIAL', 'LIFETIME'];
|
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; }
|
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 map = (l: RawLicense, programs: Map<number, string>, zone: string): NormalizedResource => mapLicense(l, programs, zone, now());
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
|
contractVersion: CONTRACT_VERSION, key: 'licensing', displayName: 'Lizenzsystem',
|
||||||
|
|
@ -235,3 +243,4 @@ export function createLicensingConnector(deps: LicensingDeps = {}): Connector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export const licensingConnector = createLicensingConnector();
|
export const licensingConnector = createLicensingConnector();
|
||||||
|
export * from './admin.js';
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
async function upsert(instanceId: string, r: NormalizedResource): Promise<string> {
|
||||||
await run(
|
await run(
|
||||||
`INSERT INTO resources (id, instance_id, external_ref, type, name, state, valid_from, valid_until, data_json, synced_at)
|
`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;
|
return due.length;
|
||||||
}
|
}
|
||||||
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
|
export { ACTION_CAPABILITY, DESTRUCTIVE_ACTIONS };
|
||||||
|
export { createLicensingAdmin, type LicensingAdmin, type LicenseDetail, type LicensingCatalog } from '@kc/connector-licensing';
|
||||||
export type { ActionName, Capability };
|
export type { ActionName, Capability };
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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> {
|
export async function hasModule(key: string): Promise<boolean> {
|
||||||
const e = await getEntitlement();
|
const e = await getEntitlement();
|
||||||
if (e.source === 'unconfigured' || e.source === 'unchecked') return true;
|
return e.valid && (e.modules.length === 0 || e.modules.includes(key));
|
||||||
return e.valid && e.modules.includes(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
|
export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null }
|
||||||
/** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung
|
/** 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
|
* 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.
|
* und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. Grenze aus der Edition
|
||||||
*
|
* (Lizenz oder Testmodus). */
|
||||||
* 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. */
|
|
||||||
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
|
export async function assertCustomerCapacity(c: PoolConnection): Promise<CapacityCheck> {
|
||||||
const ent = await getEntitlement();
|
const ed = await getEdition();
|
||||||
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 };
|
|
||||||
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
|
await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']);
|
||||||
try {
|
try {
|
||||||
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
|
const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c);
|
||||||
const count = Number(row?.n ?? 0);
|
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 };
|
if (ed.customerLimit !== null && count >= ed.customerLimit) {
|
||||||
return { allowed: true, count, limit: ent.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 {
|
} finally {
|
||||||
await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']);
|
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> {
|
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)]);
|
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue