From 17267b7c05bbf4c58eb228b61fd8bbf853d0dda2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:31:19 +0200 Subject: [PATCH] KC@FlessingLabs 1.0.0: Testmodus ohne Lizenz, Installationsanleitung, Nutzungsbedingungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 30 ++-- LICENSE.md | 18 +++ README.md | 128 +++++++++++++----- apps/api/src/cli/index.ts | 3 + apps/api/src/modules/backup/index.ts | 2 + apps/api/src/modules/discord/index.ts | 3 + apps/api/src/modules/identity/index.ts | 3 + apps/api/src/modules/invoices/index.ts | 2 + apps/api/src/modules/license/index.ts | 7 +- apps/api/src/modules/mail/index.ts | 2 + apps/api/src/ops/backup.ts | 7 +- .../app/(app)/einstellungen/lizenz/page.tsx | 11 +- apps/web/src/app/(app)/layout.tsx | 4 +- apps/worker/src/discord.ts | 5 +- apps/worker/src/mailbox.ts | 2 + ops/systemd/kc-api.service | 21 +++ ops/systemd/kc-backup-request.path | 7 + ops/systemd/kc-backup-request.service | 21 +++ ops/systemd/kc-backup.service | 20 +++ ops/systemd/kc-backup.timer | 8 ++ ops/systemd/kc-restore-test.service | 20 +++ ops/systemd/kc-restore-test.timer | 8 ++ ops/systemd/kc-web.service | 19 +++ ops/systemd/kc-worker.service | 21 +++ package.json | 2 +- packages/platform/src/license.ts | 67 +++++++-- 26 files changed, 368 insertions(+), 73 deletions(-) create mode 100644 LICENSE.md create mode 100644 ops/systemd/kc-api.service create mode 100644 ops/systemd/kc-backup-request.path create mode 100644 ops/systemd/kc-backup-request.service create mode 100644 ops/systemd/kc-backup.service create mode 100644 ops/systemd/kc-backup.timer create mode 100644 ops/systemd/kc-restore-test.service create mode 100644 ops/systemd/kc-restore-test.timer create mode 100644 ops/systemd/kc-web.service create mode 100644 ops/systemd/kc-worker.service diff --git a/.env.example b/.env.example index 0e64b8b..1b79651 100644 --- a/.env.example +++ b/.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_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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c169cb6 --- /dev/null +++ b/LICENSE.md @@ -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 diff --git a/README.md b/README.md index b067876..e79c766 100644 --- a/README.md +++ b/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 | |---|---|---|---| -| `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/` 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 ''; + 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:` (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/` 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. diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 1b6e203..9207405 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -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(); diff --git a/apps/api/src/modules/backup/index.ts b/apps/api/src/modules/backup/index.ts index 3e3bbd1..d251ac2 100644 --- a/apps/api/src/modules/backup/index.ts +++ b/apps/api/src/modules/backup/index.ts @@ -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(); diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts index a1ffa11..f8a543a 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -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); diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts index 98ff27f..ffbd351 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -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 } }); diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 7d634a5..36613c3 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -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'); diff --git a/apps/api/src/modules/license/index.ts b/apps/api/src/modules/license/index.ts index 06a669a..f245dea 100644 --- a/apps/api/src/modules/license/index.ts +++ b/apps/api/src/modules/license/index.ts @@ -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) => { diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 0dacce1..c0503b6 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -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); } diff --git a/apps/api/src/ops/backup.ts b/apps/api/src/ops/backup.ts index c7087a6..be408c1 100644 --- a/apps/api/src/ops/backup.ts +++ b/apps/api/src/ops/backup.ts @@ -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 [])).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 { diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx index 16e186d..2cae427 100644 --- a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -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 = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; const MODULE_LABEL: Record = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' }; @@ -38,13 +39,21 @@ export default function LizenzPage() {

Lizenz

Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).

{err && {err}}{ok && {ok}} - {!s.configured && Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.} + {s.edition.mode === 'trial' && Testmodus ({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.} {s.configured && s.source === 'unchecked' && Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.} {s.configured && s.source !== 'unchecked' && !s.valid && Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.} {s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.} {s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.} {s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.} +
+

Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}

+
+
Personal-Konten
{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}
+
Kunden
{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}
+ {s.edition.allFeatures.map((f) =>
{f.label}
{f.enabled ? enthalten : nur mit Lizenz}
)} +
+

Status

diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index cd93719..8e3d445 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -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(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 }) { )}
Kundencenter
- {nav}
{children}
+ {nav}
{trial && path !== '/einstellungen/lizenz' &&
{trial} Lizenz hinterlegen
}{children}
{!enrollNeeded &&