diff --git a/.env.example b/.env.example index 1b79651..0e64b8b 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,21 @@ -# 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 ---- +# Beispielwerte OHNE Geheimnisse. Echte Werte liegen in /etc/kundencenter/*.env (chmod 600). DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=kundencenter DB_USER=kundencenter DB_PASSWORD= - -# ---- /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_SECRET_KEY= # 32 Byte base64 (openssl rand -base64 32); Verschluesselung von TOTP-Secrets +KC_BASE_URL=http://localhost:4101 KC_API_PORT=4100 -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 +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 diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index c169cb6..0000000 --- a/LICENSE.md +++ /dev/null @@ -1,18 +0,0 @@ -# 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 e79c766..b067876 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,52 @@ -# KC@FlessingLabs – Kundencenter +# Kundencenter -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). +Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, TypeScript). | Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe | |---|---|---|---| -| `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 | +| `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 | -## 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`). +## 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). -## Testmodus und Lizenz -Ohne Lizenzschlüssel läuft das Kundencenter im **Testmodus**: +## 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 | 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 -``` +## 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). ## Betrieb ```bash systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always) journalctl -u kc-api -f # Logs -pnpm cli reset-password --email=… # Passwort zurücksetzen +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=… ``` 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. - -## Discord-Bot -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. - -## 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. +`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. -## 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. +## 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“). + +## 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). diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 9207405..1b6e203 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -3,7 +3,6 @@ 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); @@ -42,8 +41,6 @@ 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 d251ac2..3e3bbd1 100644 --- a/apps/api/src/modules/backup/index.ts +++ b/apps/api/src/modules/backup/index.ts @@ -13,7 +13,6 @@ 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'; @@ -112,7 +111,6 @@ 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 f8a543a..9406a8e 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -5,7 +5,6 @@ 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'; @@ -13,7 +12,6 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs const settingsView = (s: any) => ({ enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id, clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id, - ticketCategoryId: s.ticket_category_id, ticketLogChannelId: s.ticket_log_channel_id, supportRoleIds: s.support_role_ids, configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at, }); const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`; @@ -32,18 +30,11 @@ export const discordModule: KcModule = { token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(), ticketChannelId: z.string().trim().regex(ID).nullable(), - // optional, damit ältere Formulare die Werte nicht versehentlich löschen - ticketCategoryId: z.string().trim().regex(ID).nullable().optional(), ticketLogChannelId: z.string().trim().regex(ID).nullable().optional(), - supportRoleIds: z.string().trim().regex(/^\d{15,25}(\s*,\s*\d{15,25})*$/).max(500).nullable().optional(), enabled: z.boolean().default(false), clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(), }).parse(req.body); - if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED'); const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?']; const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id]; - if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); } - if (b.ticketLogChannelId !== undefined) { sets.push('ticket_log_channel_id = ?'); params.push(b.ticketLogChannelId); } - if (b.supportRoleIds !== undefined) { sets.push('support_role_ids = ?'); params.push(b.supportRoleIds ? b.supportRoleIds.split(',').map((x) => x.trim()).join(',') : null); } if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); } if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); } await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params); @@ -105,7 +96,6 @@ 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 ffbd351..98ff27f 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -8,7 +8,6 @@ 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'; @@ -240,8 +239,6 @@ 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/index.ts b/apps/api/src/modules/index.ts index f0f59d6..fb61559 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -14,7 +14,6 @@ import { backupModule } from './backup/index.js'; import { mailModule } from './mail/index.js'; import { discordModule } from './discord/index.js'; import { licenseModule } from './license/index.js'; -import { licensingModule } from './licensing/index.js'; /** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */ -export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule, licensingModule]; +export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule]; diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 36613c3..7d634a5 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -12,7 +12,6 @@ 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'; @@ -488,7 +487,6 @@ 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 f245dea..06a669a 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, getEdition, getEntitlement, setLicenseKey, FEATURE_LABEL, LICENSED_FEATURES, LicenseCheckError } from '../../core/license.js'; +import { checkLicenseNow, getEntitlement, setLicenseKey, 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,10 +20,7 @@ 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')"); - 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) }; + 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) }; }); app.put('/admin/license/settings', async (req) => { diff --git a/apps/api/src/modules/licensing/index.ts b/apps/api/src/modules/licensing/index.ts deleted file mode 100644 index 038531a..0000000 --- a/apps/api/src/modules/licensing/index.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; -import { randomUUID } from 'node:crypto'; -import { ConnectorError } from '@kc/connector-sdk'; -import { createLicensingAdmin, loadInstance, upsertResource, type LicensingAdmin } from '@kc/connectors'; -import { one, query, run } from '../../core/db.js'; -import { rl } from '../../core/config.js'; -import { audit } from '../../core/audit.js'; -import { enqueue } from '../../core/jobs.js'; -import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; -import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js'; -import { can, canInOrg } from '../../core/policy.js'; -import type { KcModule } from '../../core/module.js'; - -/** - * Lizenzverwaltung: Übersicht, Vergabe und Pflege von Kunden-Lizenzen im eigenen Lizenzsystem (licensing.flessinglabs.com). - * Datenbasis der Übersicht ist der Abgleich (resources, type = 'license'); Detail und Änderungen gehen live ans Lizenzsystem. - * Vergabe "mit Vertrag" läuft über den normalen Bestellweg (POST /orders), hier nur die Vergabe ohne Berechnung. - * Nicht zu verwechseln mit dem Modul "license" (eigene Lizenz dieser Installation). - */ -const json = (v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T)); -const LIC_SQL = `SELECT r.*, i.connector_key, i.health, i.enabled AS inst_enabled, o.name AS org_name, o.customer_number, - (SELECT c.id FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_id, - (SELECT c.number FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_number - FROM resources r JOIN connector_instances i ON i.id = r.instance_id LEFT JOIN organizations o ON o.id = r.org_id - WHERE r.type = 'license' AND i.connector_key = 'licensing'`; - -function listView(r: any) { - const d = json<{ details?: Record; limits?: Record }>(r.data_json, {}); - const x = d.details ?? {}; - return { - id: r.id, name: r.name, state: r.state, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since, - orgId: r.org_id, orgName: r.org_name ?? null, customerNumber: r.customer_number ?? null, contractId: r.contract_id ?? null, contractNumber: r.contract_number ?? null, - program: x.program ?? null, programId: x.programId ?? null, product: x.product ?? null, edition: x.edition ?? null, keyMasked: x.licenseKeyMasked ?? null, - activationsUsed: x.activationsUsed ?? 0, activationLimit: x.activationLimit ?? null, trial: !!x.trial, trialPending: !!x.trialPending, addon: !!x.addon, - parentLicenseId: x.parentLicenseId ?? null, revoked: !!x.revoked, externalRef: r.external_ref, - }; -} -const access = (a: AuthContext, r: any) => can(a.principal, 'licenses.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.read', 'licenses.read')); -/** Kunden-Selbstbedienung: Inhaber/Admin der Organisation dürfen eigene Geräte freigeben (Aktivierung zurücksetzen). */ -const canResetActivation = (a: AuthContext, r: any) => can(a.principal, 'licenses.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.manage', 'licenses.write')); -async function loadLicense(id: string) { return one(`${LIC_SQL} AND r.id = ?`, [id]); } -async function adminFor(instanceId: string, correlationId: string): Promise { - const { inst, ctx } = await loadInstance(instanceId, correlationId); - if (!inst.enabled) throw new AppError(409, 'CONNECTOR_DISABLED', 'Die Verbindung zum Lizenzsystem ist deaktiviert.'); - return createLicensingAdmin(ctx); -} -/** Fehler des Lizenzsystems verständlich weitergeben (abgelehnte Eingaben mit dessen Begründung). */ -function providerError(e: unknown): never { - if (e instanceof ConnectorError) { - if (e.code === 'INVALID_INPUT' || e.code === 'CONFLICT') throw new AppError(e.code === 'CONFLICT' ? 409 : 400, 'LICENSING_REJECTED', `Das Lizenzsystem lehnt das ab: ${e.detail ?? e.userMessage}`); - if (e.code === 'NOT_FOUND') throw new AppError(404, 'LICENSING_NOT_FOUND', 'Die Lizenz wurde im Lizenzsystem nicht gefunden.'); - throw new AppError(502, 'CONNECTOR_ERROR', `Lizenzsystem: ${e.userMessage}`); - } - throw e; -} -/** Nach einer Änderung: lokalen Stand sofort aktualisieren (Übersicht) und vollständigen Abgleich anstoßen. */ -async function refresh(admin: LicensingAdmin, r: any, raw: Parameters[0] | undefined, correlationId: string) { - if (raw) await upsertResource(r.instance_id, await admin.normalize(raw)).catch(() => undefined); - await enqueue('connector.sync', { instanceId: r.instance_id }, { idempotencyKey: `sync:${r.instance_id}:licensing:${Math.floor(Date.now() / 15000)}`, correlationId }); -} -const reasonSchema = z.string().trim().max(255).optional(); - -export const licensingModule: KcModule = { - name: 'licensing', - permissions: { - staff: { support: ['licenses.read'], accounting: ['licenses.read'], admin: ['licenses.read', 'licenses.write'], superadmin: ['licenses.read', 'licenses.write'] }, - org: { owner: ['licenses.read', 'licenses.manage'], admin: ['licenses.read', 'licenses.manage'], member: ['licenses.read'] }, - }, - register(app: FastifyInstance) { - // ---- Übersicht ------------------------------------------------------------------------------------------- - app.get('/licenses', async (req) => { - const a = requireAuth(req); - const q = z.object({ org: z.string().uuid().optional(), state: z.enum(['active', 'suspended', 'expired']).optional(), expiring: z.enum(['1']).optional(), unassigned: z.enum(['1']).optional(), q: z.string().trim().max(100).optional() }).parse(req.query); - const staff = can(a.principal, 'licenses.read'); - const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId); - if (orgs && orgs.length === 0) return []; - const where: string[] = []; const params: unknown[] = []; - if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); } - if (q.state) { where.push('r.state = ?'); params.push(q.state); } - if (q.expiring) where.push("r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)"); - if (q.unassigned && staff) where.push('r.org_id IS NULL'); - if (q.q) { where.push('(r.name LIKE ? OR o.name LIKE ? OR o.customer_number = ? OR r.external_ref = ?)'); params.push(`%${q.q}%`, `%${q.q}%`, q.q, q.q); } - const rows = await query(`${LIC_SQL}${where.length ? ' AND ' + where.join(' AND ') : ''} ORDER BY (r.valid_until IS NULL), r.valid_until, r.name LIMIT 1000`, params); - return rows.map(listView); - }); - app.get('/admin/licenses/summary', async (req) => { - requirePermission(req, 'licenses.read'); - const s = await one(`SELECT COUNT(*) AS total, SUM(r.state = 'active') AS active, SUM(r.state = 'suspended') AS suspended, SUM(r.state = 'expired') AS expired, - SUM(r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) AS expiring, SUM(r.org_id IS NULL) AS unassigned - FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.type = 'license' AND i.connector_key = 'licensing' AND r.missing_since IS NULL`); - const n = (v: unknown) => Number(v ?? 0); - return { total: n(s?.total), active: n(s?.active), suspended: n(s?.suspended), expired: n(s?.expired), expiring: n(s?.expiring), unassigned: n(s?.unassigned) }; - }); - - // ---- Detail (live aus dem Lizenzsystem, Rückfall auf den letzten Abgleich) -------------------------------- - app.get('/licenses/:id', async (req) => { - const a = requireAuth(req); - const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const r = await loadLicense(id); - if (!r || !access(a, r)) throw notFound(); - const staff = can(a.principal, 'licenses.read'); const write = can(a.principal, 'licenses.write'); - const base = listView(r); - let live: Awaited> | null = null; let liveError: string | null = null; - try { live = await (await adminFor(r.instance_id, req.correlationId)).get(r.external_ref); } - catch (e) { liveError = e instanceof ConnectorError ? e.userMessage : e instanceof AppError ? e.message : 'Das Lizenzsystem ist nicht erreichbar.'; } - if (live) await upsertResource(r.instance_id, live.resource).catch(() => undefined); - // Add-ons dieser Lizenz und (bei Add-ons) die Basislizenz, soweit im Kundencenter bekannt - const addons = (await query(`${LIC_SQL} AND r.instance_id = ? AND JSON_VALUE(r.data_json, '$.details.parentLicenseId') = ?`, [r.instance_id, r.external_ref])) - .filter((x) => access(a, x)).map(listView); - const parentRef = live?.raw.parent_license_id ?? base.parentLicenseId; - const parent = parentRef ? await one(`${LIC_SQL} AND r.instance_id = ? AND r.external_ref = ?`, [r.instance_id, String(parentRef)]) : null; - const history = staff ? (await query("SELECT action, actor_id, result, ts AS created_at FROM audit_events WHERE resource_type = 'resource' AND resource_id = ? ORDER BY id DESC LIMIT 30", [id])).map((h) => ({ action: h.action, result: h.result, at: h.created_at, actorId: h.actor_id })) : []; - const actorNames = new Map((history.length ? await query(`SELECT id, name FROM users WHERE id IN (${[...new Set(history.map((h) => h.actorId).filter(Boolean))].map(() => '?').join(',') || 'NULL'})`, [...new Set(history.map((h) => h.actorId).filter(Boolean))]) : []).map((u) => [u.id, u.name])); - const caps = json((await one('SELECT capabilities_json FROM connector_instances WHERE id = ?', [r.instance_id]))?.capabilities_json, []); - return { - ...(live ? listView({ ...r, name: live.resource.name, state: live.resource.state, valid_from: live.resource.validFrom, valid_until: live.resource.validUntil, data_json: { details: live.resource.details } }) : base), - live: !!live, liveError, - activations: live?.activations ?? [], entitlement: live?.entitlement ?? null, limits: live?.limits ?? null, - origin: staff ? (live?.origin ?? null) : null, providerStatus: live?.raw.status ?? null, revokeReason: staff ? (live?.raw.revoke_reason ?? null) : null, - durationType: live?.raw.duration_type ?? null, productId: live?.raw.product_id ?? null, lastCheckAt: live?.raw.last_check_at ?? null, - addons, parent: parent && access(a, parent) ? listView(parent) : null, - history: history.map((h) => ({ ...h, actor: h.actorId ? (actorNames.get(h.actorId) ?? null) : 'System' })), - can: { reveal: caps.includes('secret.reveal') && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'))), // gleiche Regel wie /resources/:id/reveal - resetActivation: !!live && canResetActivation(a, r), manage: write && !!live }, - }; - }); - - // ---- Aktivierung (Gerät) freigeben: Personal oder Kunde (Inhaber/Admin) für die eigene Lizenz ------------ - app.delete('/licenses/:id/activations/:activationId', { config: rl(10, '10 minutes') }, async (req) => { - const a = requireAuth(req); - const { id, activationId } = z.object({ id: z.string().uuid(), activationId: z.coerce.number().int().positive() }).parse(req.params); - const r = await loadLicense(id); - if (!r || !access(a, r)) throw notFound(); - if (!canResetActivation(a, r)) throw forbidden('Geräte dieser Lizenz können nur vom Inhaber oder Support freigegeben werden', 'ACTIVATION_RESET_FORBIDDEN'); - const admin = await adminFor(r.instance_id, req.correlationId); - try { await admin.deleteActivation(r.external_ref, activationId); } catch (e) { providerError(e); } - await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.activation.reset', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { activationId } }); - await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId); - return { ok: true }; - }); - - // ---- Personal: Limits, Produkt (Upgrade/Testumwandlung), Entitlement, Lebenszyklus -------------------------- - app.patch('/admin/licenses/:id', async (req) => { - const a = requirePermission(req, 'licenses.write'); - const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const b = z.object({ - maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).nullable().optional(), - customerLimit: z.number().int().min(0).max(1000000).nullable().optional(), graceDays: z.number().int().min(0).max(365).nullable().optional(), - productId: z.number().int().positive().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), expiresAt: z.iso.datetime().nullable().optional(), - }).parse(req.body); - const r = await loadLicense(id); if (!r) throw notFound(); - const body: Record = {}; - if (b.maxActivations !== undefined) body.max_activations = b.maxActivations; - if (b.userLimit !== undefined) body.user_limit = b.userLimit; - if (b.customerLimit !== undefined) body.customer_limit = b.customerLimit; - if (b.graceDays !== undefined) body.grace_days = b.graceDays; - if (b.productId !== undefined) body.product_id = b.productId; - if (b.durationType !== undefined) body.duration_type = b.durationType; - if (b.expiresAt !== undefined) body.expires_at = b.expiresAt; - if (!Object.keys(body).length) throw badRequest('Keine Änderung angegeben'); - const admin = await adminFor(r.instance_id, req.correlationId); - let raw; try { raw = await admin.update(r.external_ref, body); } catch (e) { providerError(e); } - await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b }); - await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw ?? raw, req.correlationId); - return { ok: true }; - }); - app.post('/admin/licenses/:id/entitlement', async (req) => { - const a = requirePermission(req, 'licenses.write'); - const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const b = z.object({ fromProduct: z.boolean().default(false), planKey: z.string().trim().max(50).optional(), modules: z.array(z.string().trim().min(1).max(100)).max(200).optional(), - customerLimit: z.number().int().min(0).max(1000000).optional(), clearCustomerLimit: z.boolean().default(false), reason: reasonSchema }).parse(req.body); - const r = await loadLicense(id); if (!r) throw notFound(); - const admin = await adminFor(r.instance_id, req.correlationId); - try { await admin.entitlement(r.external_ref, { from_product: b.fromProduct, plan_key: b.planKey, modules: b.modules, customer_limit: b.customerLimit, clear_customer_limit: b.clearCustomerLimit, reason: b.reason ?? `Kundencenter (${a.user.name})` }); } catch (e) { providerError(e); } - await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.entitlement', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b }); - await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId); - return { ok: true }; - }); - app.post('/admin/licenses/:id/lifecycle', async (req) => { - const a = requirePermission(req, 'licenses.write'); - const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend', 'revoke']), until: z.iso.datetime().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), count: z.number().int().min(1).max(120).optional(), reason: reasonSchema }).parse(req.body); - if (b.action === 'extend' && !b.until && !b.durationType) throw badRequest('Bitte ein Datum oder eine Laufzeit angeben'); - if (b.action === 'revoke' && !b.reason) throw badRequest('Bitte einen Grund für den Widerruf angeben'); - const r = await loadLicense(id); if (!r) throw notFound(); - // Idempotenz pro Bestätigungsdialog (Header), sonst pro Minute: ein Doppelklick verlängert nicht zweimal - const hdr = req.headers['idempotency-key']; - const key = typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : `${id}:${b.action}:${b.until ?? ''}:${b.durationType ?? ''}:${b.count ?? ''}:${Math.floor(Date.now() / 60000)}`; - const body: Record = { reason: b.reason ?? `Kundencenter (${a.user.name})` }; - if (b.action === 'extend') Object.assign(body, b.until ? { until: b.until } : { duration_type: b.durationType, count: b.count ?? 1 }); - const admin = await adminFor(r.instance_id, req.correlationId); - let out; try { out = await admin.lifecycle(r.external_ref, b.action, body, `kc:${key}`); } catch (e) { providerError(e); } - await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `license.${b.action}`, resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, changed: out?.changed } }); - await refresh(admin, r, out?.license, req.correlationId); - return { ok: true, changed: !!out?.changed }; - }); - app.patch('/admin/licenses/:id/assign', async (req) => { - const a = requirePermission(req, 'licenses.write'); - const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const b = z.object({ orgId: z.string().uuid().nullable() }).parse(req.body); - const r = await loadLicense(id); if (!r) throw notFound(); - if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden'); - await run('UPDATE resources SET org_id = ? WHERE id = ?', [b.orgId, id]); - await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id }, after: { orgId: b.orgId } }); - return { ok: true }; - }); - - // ---- Vergabe ohne Berechnung (Kulanz, Test, intern). Mit Vertrag: normaler Bestellweg (POST /orders). --------- - app.get('/admin/licenses/catalog', async (req) => { - requirePermission(req, 'licenses.write'); - const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1"); - if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet (Einstellungen → Verbindungen).'); - let catalog; try { catalog = await (await adminFor(inst.id, req.correlationId)).catalog(); } catch (e) { providerError(e); } - // Produkte des Kundencenters, die eine Lizenz bereitstellen (für "mit Vertrag") - const shop = (await query(`SELECT p.id, v.name, v.recurring_cents, v.setup_cents, v.price_basis, v.billing_interval, v.term_months, v.provisioning_json - FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.status = 'active' AND p.connector_instance_id = ? ORDER BY v.name`, [inst.id])) - .map((p) => ({ id: p.id, name: p.name, recurringCents: p.recurring_cents, setupCents: p.setup_cents, priceBasis: p.price_basis, interval: p.billing_interval, termMonths: p.term_months, provisioning: json(p.provisioning_json, {}) })); - return { instanceId: inst.id, ...catalog, shopProducts: shop }; - }); - app.post('/admin/licenses', { config: rl(20, '1 minute') }, async (req) => { - const a = requirePermission(req, 'licenses.write'); - const b = z.object({ - orgId: z.string().uuid(), kind: z.enum(['license', 'trial', 'addon']), programId: z.number().int().positive(), productId: z.number().int().positive(), - parentId: z.string().uuid().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).default('YEAR'), expiresAt: z.iso.datetime().optional(), - maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).optional(), customerLimit: z.number().int().min(0).max(1000000).optional(), - keyPrefix: z.enum(['PREMIUM', 'TRIAL', 'LIFETIME']).optional(), note: z.string().trim().max(50).optional(), - }).parse(req.body); - const org = await one("SELECT o.id, o.name, o.customer_number, o.status FROM organizations o WHERE o.id = ?", [b.orgId]); - if (!org) throw badRequest('Kunde nicht gefunden'); - if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann keine Lizenz vergeben werden', 'ORG_INACTIVE'); - const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY (m.role = 'owner') DESC, m.created_at LIMIT 1", [b.orgId]); - const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1"); - if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet.'); - let parentRef: number | undefined; - if (b.kind === 'addon') { - if (!b.parentId) throw badRequest('Für ein Add-on bitte die Basislizenz wählen'); - const p = await loadLicense(b.parentId); - if (!p || p.org_id !== b.orgId || p.instance_id !== inst.id) throw badRequest('Die Basislizenz gehört nicht zu diesem Kunden'); - parentRef = Number(p.external_ref); - } - const ref = `kc-${randomUUID()}`; // Herkunft: verhindert Doppelanlage bei Wiederholung (source + external_ref eindeutig) - const customer = { source: 'kundencenter', customer_name: org.name, customer_email: owner?.email ?? null, customer_contact: owner?.name ?? null, customer_reference: org.customer_number, order_ref: b.note ? b.note.slice(0, 50) : 'ohne Berechnung', external_ref: ref }; - const admin = await adminFor(inst.id, req.correlationId); - let raw; - try { - if (b.kind === 'trial') { - if (!owner?.email) throw badRequest('Für einen Test braucht der Kunde eine E-Mail-Adresse (Ansprechpartner).'); - raw = await admin.createTrial({ program_id: b.programId, product_id: b.productId, max_activations: b.maxActivations, key_prefix: b.keyPrefix, ...customer }); - } else { - raw = await admin.create({ - program_id: b.programId, product_id: b.productId, duration_type: b.durationType, is_active: true, ...(b.expiresAt ? { expires_at: b.expiresAt } : {}), - max_activations: b.maxActivations, user_limit: b.userLimit, customer_limit: b.kind === 'addon' ? undefined : b.customerLimit, key_prefix: b.keyPrefix, - parent_license_id: parentRef, ...customer, - }); - } - } catch (e) { if (e instanceof AppError) throw e; providerError(e); } - if (!raw || typeof raw.id !== 'number') throw new AppError(502, 'CONNECTOR_ERROR', 'Unerwartete Antwort des Lizenzsystems'); - const resourceId = await upsertResource(inst.id, await admin.normalize(raw)); - await run("UPDATE resources SET org_id = ?, customer_actions = COALESCE(customer_actions, '[]') WHERE id = ?", [b.orgId, resourceId]); - await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'license.issue', resourceType: 'resource', resourceId, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), - after: { kind: b.kind, programId: b.programId, productId: b.productId, durationType: b.kind === 'trial' ? 'TRIAL' : b.durationType, expiresAt: b.expiresAt, maxActivations: b.maxActivations, licenseId: raw.id, billing: 'none', note: b.note } }); - return { id: resourceId }; - }); - }, -}; diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index c0503b6..0dacce1 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -6,7 +6,6 @@ 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'; @@ -125,7 +124,6 @@ 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 be408c1..c7087a6 100644 --- a/apps/api/src/ops/backup.ts +++ b/apps/api/src/ops/backup.ts @@ -13,7 +13,6 @@ 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 } @@ -120,11 +119,9 @@ 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) }); } } } + 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) }); } - if (remoteAllowed) for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} }); + 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)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 38cc7a7..adc8776 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -9,7 +9,6 @@ interface Sys { jobs: Record; oldestPendingJob: string | null; b export default function Dashboard() { const { me, can } = useSession(); const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null); - const [licenses, setLicenses] = useState<{ active: number; expiring: number; unassigned: number } | null>(null); const [customers, setCustomers] = useState(null); const [sys, setSys] = useState(null); const [err, setErr] = useState(''); useEffect(() => { if (me?.kind === 'customer') { @@ -19,7 +18,6 @@ export default function Dashboard() { if (!me || me.kind !== 'staff') return; if (can('customers.read')) api('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e))); if (can('jobs.read')) api('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e))); - if (can('licenses.read')) api<{ active: number; expiring: number; unassigned: number }>('GET', '/admin/licenses/summary').then(setLicenses).catch(() => undefined); }, [me, can]); if (!me) return null; const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0; @@ -39,7 +37,6 @@ export default function Dashboard() { ) : (<>
{customers !== null &&
{customers}

Kunden

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

Aktive Lizenzen

{licenses.expiring > 0 &&

{licenses.expiring} laufen in 30 Tagen ab

}{licenses.unassigned > 0 &&

{licenses.unassigned} ohne Kunde

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

Wartende Aufträge

} {sys?.backup &&

Backup

Details und Einstellungen

{!sys.backup.configured ? <>

Nicht eingerichtet.

Kein Backup diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index ec6d536..fad2bc9 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field, fmt } from '@/components/ui'; -interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: 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; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string } /** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */ function Guide({ redirectUri }: { redirectUri: string }) { @@ -13,7 +13,7 @@ function Guide({ redirectUri }: { redirectUri: string }) {
  1. Anwendung anlegen: Auf discord.com/developers/applications auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).
  2. Bot-Token erzeugen: 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 Message Content Intent aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).
  3. -
  4. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  5. +
  6. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands 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.
  7. Kunden-Anmeldung (OAuth) einrichten: Im Reiter „OAuth2 → General“ die Client ID und (unter „Reset Secret“) das Client Secret kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: {redirectUri}
  8. IDs ermitteln: 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.
  9. Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.
  10. @@ -37,7 +37,7 @@ export default function DiscordSettings() { const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null); const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? ''); try { - await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), ticketCategoryId: v('ticketCategoryId'), ticketLogChannelId: v('ticketLogChannelId'), supportRoleIds: v('supportRoleIds'), 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'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) }); setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); } } @@ -69,14 +69,7 @@ export default function DiscordSettings() { - -
-

Tickets als eigene Kanäle

-

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 #ticket-log angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.

-
- - - +

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx index 2cae427..16e186d 100644 --- a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -9,7 +9,6 @@ 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' }; @@ -39,21 +38,13 @@ 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.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 && Noch kein Lizenzschlüssel hinterlegt. Solange keiner hinterlegt ist, gilt keine Kunden-/Modulgrenze.} {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 8e3d445..dd1eeb5 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -9,8 +9,6 @@ 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]); @@ -23,7 +21,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { ); async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); } - const produkte = [(me.kind === 'customer' || can('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 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 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 = (