KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-10-02 13:31:19 +02:00
parent fdd51b18e2
commit 17267b7c05
26 changed files with 368 additions and 73 deletions

View file

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

18
LICENSE.md Normal file
View file

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

128
README.md
View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js';
import { encrypt, decrypt, randomToken } from '../../core/crypto.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest } from '../../core/errors.js';
import { featureRequiresLicense, hasFeature } from '../../core/license.js';
import { rl, config } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
@ -37,6 +38,7 @@ export const discordModule: KcModule = {
enabled: z.boolean().default(false),
clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(),
}).parse(req.body);
if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?'];
const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id];
if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); }
@ -103,6 +105,7 @@ export const discordModule: KcModule = {
/** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */
app.get('/discord/oauth/start', async (req, reply) => {
const a = requireAuth(req);
if (!(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED');
const s = await one('SELECT client_id FROM discord_settings WHERE id = 1');
if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED');
const state = randomToken(32);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
import { config } from '@kc/platform/config';
import { audit } from '@kc/platform/audit';
import { hasFeature } from '@kc/platform/license';
interface DiscordSettings {
enabled: boolean; token: string | null; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null;
@ -313,8 +314,8 @@ async function connect(s: DiscordSettings, log: (m: string) => void): Promise<vo
/** Prüft, ob sich Token/Server/Aktivierung geändert haben, und verbindet bei Bedarf neu. Für periodischen Aufruf gedacht. */
export async function syncDiscord(log: (m: string) => void): Promise<void> {
const s = await loadSettings();
cachedEnabled = s.enabled && !!s.token;
const fp = `${s.enabled}|${s.token}|${s.guildId}`;
cachedEnabled = s.enabled && !!s.token && (await hasFeature('discord')); // Discord-Bot nur mit Lizenz
const fp = `${s.enabled}|${s.token}|${s.guildId}|${cachedEnabled}`; // Lizenzwechsel verbindet/trennt ebenfalls
if (fp === lastFingerprint) return;
lastFingerprint = fp;
if (client) { await client.destroy().catch(() => undefined); client = null; }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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