Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

21
.env.example Normal file
View file

@ -0,0 +1,21 @@
# 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=
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=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

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules
dist
.next
.env
.env.*
!.env.example
*.log

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# Kundencenter
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, 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 |
## 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`).
Noch **nicht** vorhanden: Rechnungen/Zahlungen, Tickets, Selbstregistrierung, KeyHelp/Plesk-Connector.
## Modularität
API-Module liegen in `apps/api/src/modules/<name>` und implementieren `KcModule` (`core/module.ts`).
Sie werden in `modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander.
Neue Fähigkeiten (Connectoren, Rechnungen, Tickets) kommen als weitere Module hinzu.
## 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 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`.
## Tests
`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt).
Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette/Maskierung.
## Discord-Bot
In `/etc/kundencenter/discord.env` setzen: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_ADMIN_CHANNEL_ID`, `DISCORD_STAFF_USER_IDS` (kommagetrennt), dann `systemctl restart kc-worker`.
Befehle: `/kc-status`, `/kc-kunde suche:<Text>` (nur für freigegebene Discord-User, Antworten nur für sie sichtbar). In Kanäle gehen nur Ereignisse ohne personenbezogene Daten (z. B. „Neuer Kunde angelegt: K-10001“).
## 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).

37
apps/api/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "@kc/api",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "node dist/server.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"migrate": "tsx src/cli/migrate.ts",
"cli": "tsx src/cli/index.ts",
"test": "vitest run"
},
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/helmet": "^13.1.1",
"@fastify/rate-limit": "^11.2.0",
"@kc/connector-sdk": "workspace:*",
"@kc/connectors": "workspace:*",
"@kc/platform": "workspace:*",
"@node-rs/argon2": "^2.2.1",
"fastify": "^5.12.5",
"mysql2": "^3.24.4",
"nodemailer": "^10.0.10",
"otpauth": "^9.5.2",
"zod": "^4.6.5"
},
"devDependencies": {
"@kc/connector-mock": "workspace:*",
"@types/node": "^26.6.3",
"@types/nodemailer": "^8.0.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}

56
apps/api/src/cli/index.ts Normal file
View file

@ -0,0 +1,56 @@
import { randomUUID } from 'node:crypto';
import { hash } from '@node-rs/argon2';
import '../core/config.js';
import { pool, one, run } from '../core/db.js';
import { audit } from '../core/audit.js';
const [cmd, ...args] = process.argv.slice(2);
const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3);
if (cmd === 'create-superadmin') {
const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password');
if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password=<min. 12 Zeichen>'); process.exit(2); }
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); }
const id = randomUUID();
await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]);
await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } });
console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)');
} else if (cmd === 'connector-secrets') {
// Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs)
// Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password)
const { readFileSync } = await import('node:fs');
const { encryptSecrets } = await import('@kc/connectors');
const { decrypt } = await import('../core/crypto.js');
const { enqueue } = await import('../core/jobs.js');
const name = opt('name'), file = opt('file');
if (!name || !file) { console.error('Nutzung: connector-secrets --name=<Verbindung> --file=<Datei>'); process.exit(2); }
const map: Record<string, string> = { LICENSING_API_USER: 'username', LICENSING_API_PASSWORD: 'password' };
const vals: Record<string, string> = {};
for (const line of readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); if (m && map[m[1]!]) vals[map[m[1]!]!] = m[2]!; }
const inst = await one('SELECT id, connector_key, secrets_enc FROM connector_instances WHERE name = ?', [name]);
if (!inst) { console.error('Verbindung nicht gefunden'); process.exit(1); }
const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {};
await run('UPDATE connector_instances SET secrets_enc = ? WHERE id = ?', [encryptSecrets({ ...old, ...vals }), inst.id]);
await enqueue('connector.sync', { instanceId: inst.id }, { idempotencyKey: `sync:${inst.id}:secrets:${Date.now()}` });
await audit({ actorType: 'system', action: 'connector.update', resourceType: 'connector', resourceId: inst.id, connector: inst.connector_key, after: { secrets: Object.keys(vals), via: 'cli' } });
console.log('Zugangsdaten gesetzt für', name, '- Felder:', Object.keys(vals).join(', '), '- Abgleich eingeplant');
} else if (cmd === 'import-domains') {
// Domain-Preisliste (Einkauf) aus einer Textdatei einlesen: import-domains --file=/pfad/liste.txt
const { readFileSync } = await import('node:fs'); const { parsePriceList } = await import('../modules/domains/logic.js');
const file = opt('file'); if (!file) { console.error('Nutzung: import-domains --file=<Datei>'); process.exit(2); }
const { rows, skipped } = parsePriceList(readFileSync(file, 'utf8'));
for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents]);
console.log(`${rows.length} Endungen importiert, ${skipped.length} Zeilen übersprungen`, skipped);
} else if (cmd === 'backup') {
// backup run | restore-test [datei] | status
const { loadBackupConfig, runBackup, runRestoreTest, readStatus } = await import('../ops/backup.js');
const cfg = loadBackupConfig(); const sub = args[0];
if (sub === 'run') { const r = await runBackup(cfg); console.log(JSON.stringify({ ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, targets: r.targets, error: r.error }, null, 2)); process.exitCode = r.ok ? 0 : 1; }
else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; }
else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2));
else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; }
} else {
console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup');
process.exit(2);
}
await pool.end();

View file

@ -0,0 +1,23 @@
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join, dirname } from 'node:path';
import '../core/config.js';
import { pool } from '../core/db.js';
const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations');
export async function migrate(): Promise<void> {
await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name));
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
if (done.has(f)) continue;
const c = await pool.getConnection();
try {
// DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch.
const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean);
for (const st of stmts) await c.query(st);
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
console.log('migriert:', f);
} finally { c.release(); }
}
}
if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); }

View file

@ -0,0 +1,27 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { run } from './db.js';
import { randomToken, sha256 } from './crypto.js';
import { config } from './config.js';
import { sendMail } from './mail.js';
export const INVITE_DAYS = 7;
/** Legt einen eingeladenen Benutzer an und liefert den Einladungslink (Token nur als Hash gespeichert). */
export async function createInvitedUser(c: PoolConnection, u: { email: string; name: string; kind: 'customer' | 'staff'; staffRole?: string | null }): Promise<{ userId: string; token: string }> {
const userId = randomUUID();
await run('INSERT INTO users (id, email, name, kind, staff_role, status) VALUES (?,?,?,?,?,\'invited\')', [userId, u.email.toLowerCase(), u.name, u.kind, u.staffRole ?? null], c);
return { userId, token: await createToken(c, userId, 'invite') };
}
export async function createToken(c: PoolConnection | undefined, userId: string, purpose: 'invite' | 'password_reset' | 'verify_email'): Promise<string> {
const token = randomToken(32);
const hours = purpose === 'invite' ? INVITE_DAYS * 24 : 1;
await run('INSERT INTO user_tokens (id, user_id, purpose, token_hash, expires_at) VALUES (?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))', [randomUUID(), userId, purpose, sha256(token), hours], c);
return token;
}
export const inviteLink = (token: string): string => `${config.baseUrl}/einladung?token=${encodeURIComponent(token)}`;
export const resetLink = (token: string): string => `${config.baseUrl}/passwort-reset?token=${encodeURIComponent(token)}`;
export async function mailInvite(email: string, name: string, token: string): Promise<string> {
return sendMail(email, 'invite', 'Ihr Zugang zum Kundencenter', `Hallo ${name},\n\nfür Sie wurde ein Zugang eingerichtet. Bitte legen Sie hier Ihr Passwort fest (${INVITE_DAYS} Tage gültig):\n${inviteLink(token)}\n`);
}

View file

@ -0,0 +1,4 @@
/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können (Rolle ∧ Freigabe ∧ Anbieter-Fähigkeit müssen zusammenpassen). */
export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const;
export type CustomerAction = (typeof CUSTOMER_ACTIONS)[number];
export const CHILD_MANAGE: Record<string, CustomerAction | undefined> = { domain: 'domain.manage', email: 'email.manage', database: 'database.manage', ftp: 'ftp.manage' };

View file

@ -0,0 +1 @@
export * from '@kc/platform/audit';

92
apps/api/src/core/auth.ts Normal file
View file

@ -0,0 +1,92 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto';
import { one, query, run } from './db.js';
import { config } from './config.js';
import { randomToken, sha256, safeEqual } from './crypto.js';
import { forbidden, unauthorized } from './errors.js';
import { can, type Principal, type OrgRole, type StaffRole } from './policy.js';
export const COOKIE = 'kc_session';
const SESSION_HOURS = 12;
const IDLE_MINUTES = 120;
export interface AuthContext {
sessionId: string;
csrf: string;
pendingMfa: boolean;
mfaEnrolled: boolean;
user: { id: string; email: string; name: string };
principal: Principal;
}
declare module 'fastify' {
interface FastifyRequest { auth?: AuthContext; correlationId: string }
}
export async function createSession(reply: FastifyReply, userId: string, opts: { pendingMfa: boolean; ip: string; ua: string }): Promise<{ id: string; csrf: string }> {
const token = randomToken(32);
const id = randomUUID();
const csrf = randomToken(32);
await run(
`INSERT INTO sessions (id, user_id, token_hash, csrf_token, mfa_verified, pending_mfa, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? HOUR))`,
[id, userId, sha256(token), csrf, opts.pendingMfa ? 0 : 1, opts.pendingMfa ? 1 : 0, opts.ip, opts.ua.slice(0, 255), SESSION_HOURS],
);
reply.setCookie(COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: config.isProd, path: '/', maxAge: SESSION_HOURS * 3600 });
return { id, csrf };
}
async function loadAuth(req: FastifyRequest): Promise<AuthContext | undefined> {
const token = req.cookies[COOKIE];
if (!token) return undefined;
const s = await one(
`SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3)
AND s.last_seen_at > DATE_SUB(UTC_TIMESTAMP(3), INTERVAL ? MINUTE)`,
[sha256(token), IDLE_MINUTES],
);
if (!s || s.status !== 'active') return undefined;
const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]);
await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]);
return {
sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0,
user: { id: s.uid, email: s.email, name: s.name },
principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) },
};
}
/** Globale Hooks: Korrelations-ID, Sitzung laden, CSRF- und Origin-Prüfung für schreibende Requests. */
export function registerAuth(app: FastifyInstance): void {
app.decorateRequest('correlationId', '');
app.decorateRequest('auth', undefined);
app.addHook('onRequest', async (req, reply) => {
const inbound = req.headers['x-correlation-id'];
req.correlationId = typeof inbound === 'string' && /^[0-9a-f-]{36}$/i.test(inbound) ? inbound : randomUUID();
reply.header('x-correlation-id', req.correlationId);
req.auth = await loadAuth(req);
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
const origin = req.headers.origin;
if (origin && !config.allowedOrigins.has(origin)) throw forbidden('Ungültiger Origin', 'BAD_ORIGIN');
if (req.auth) {
const sent = req.headers['x-csrf-token'];
if (typeof sent !== 'string' || !safeEqual(sent, req.auth.csrf)) throw forbidden('CSRF-Token ungültig', 'BAD_CSRF');
}
}
});
}
/** Voraussetzung für geschützte Routen. Staff müssen 2FA eingerichtet haben (außer bei MFA-Einrichtung). */
export function requireAuth(req: FastifyRequest, opts: { allowPendingMfa?: boolean; allowUnenrolledStaff?: boolean } = {}): AuthContext {
const a = req.auth;
if (!a) throw unauthorized();
if (a.pendingMfa && !opts.allowPendingMfa) throw unauthorized('Zweiter Faktor erforderlich', 'MFA_REQUIRED');
if (a.principal.kind === 'staff' && !a.mfaEnrolled && !opts.allowUnenrolledStaff) throw forbidden('Für Mitarbeiter ist 2FA verpflichtend', 'MFA_ENROLL_REQUIRED');
return a;
}
export function requirePermission(req: FastifyRequest, permission: string): AuthContext {
const a = requireAuth(req);
if (!can(a.principal, permission)) throw forbidden();
return a;
}
export const clientIp = (req: FastifyRequest): string => req.ip;

View file

@ -0,0 +1,5 @@
export * from '@kc/platform/config';
import { config } from '@kc/platform/config';
/** Rate-Limits werden im Testmodus praktisch abgeschaltet, damit Tests sich nicht selbst aussperren. */
export const rl = (max: number, timeWindow: string) => ({ rateLimit: { max: config.env === 'test' ? 100000 : max, timeWindow } });

View file

@ -0,0 +1 @@
export * from '@kc/platform/crypto';

1
apps/api/src/core/db.ts Normal file
View file

@ -0,0 +1 @@
export * from '@kc/platform/db';

View file

@ -0,0 +1,8 @@
export class AppError extends Error {
constructor(public status: number, public code: string, message: string) { super(message); }
}
export const badRequest = (m: string, code = 'BAD_REQUEST') => new AppError(400, code, m);
export const unauthorized = (m = 'Nicht angemeldet', code = 'UNAUTHENTICATED') => new AppError(401, code, m);
export const forbidden = (m = 'Keine Berechtigung', code = 'FORBIDDEN') => new AppError(403, code, m);
export const notFound = (m = 'Nicht gefunden') => new AppError(404, 'NOT_FOUND', m);
export const conflict = (m: string, code = 'CONFLICT') => new AppError(409, code, m);

View file

@ -0,0 +1 @@
export * from '@kc/platform/jobs';

23
apps/api/src/core/mail.ts Normal file
View file

@ -0,0 +1,23 @@
import nodemailer from 'nodemailer';
import { config } from './config.js';
import { run } from './db.js';
const transport = config.smtp
? nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined })
: null;
/** Versand mit Protokoll. Ohne SMTP wird nur "not_configured" protokolliert (Inhalt bewusst nicht gespeichert). */
export async function sendMail(to: string, template: string, subject: string, text: string): Promise<'sent' | 'failed' | 'not_configured'> {
if (!transport || !config.smtp) {
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'not_configured']);
return 'not_configured';
}
try {
await transport.sendMail({ from: config.smtp.from, to, subject, text });
await run('INSERT INTO mail_log (to_email, template, status) VALUES (?,?,?)', [to, template, 'sent']);
return 'sent';
} catch (e) {
await run('INSERT INTO mail_log (to_email, template, status, error) VALUES (?,?,?,?)', [to, template, 'failed', String((e as Error).message).slice(0, 300)]);
return 'failed';
}
}

View file

@ -0,0 +1,10 @@
import type { FastifyInstance } from 'fastify';
import type { OrgRole, StaffRole } from './policy.js';
/** Vertrag für Anwendungsmodule. Module kommunizieren nur über core/* und Domain-Events (Jobs), nie über fremde Module. */
export interface KcModule {
name: string;
/** Zusätzliche Rechte, die das Modul in die Policy-Schicht einträgt. */
permissions?: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> };
register(app: FastifyInstance): void | Promise<void>;
}

View file

@ -0,0 +1,42 @@
/** Zentrale Policy-Schicht: RBAC (Staff-Rollen, Org-Rollen) + objektbezogene Prüfung. */
export type StaffRole = 'support' | 'accounting' | 'admin' | 'superadmin';
export type OrgRole = 'owner' | 'admin' | 'member';
const STAFF: Record<StaffRole, string[]> = {
support: ['customers.read', 'users.read'],
accounting: ['customers.read'],
admin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'audit.read', 'jobs.read'],
superadmin: ['customers.read', 'customers.write', 'users.read', 'users.write', 'users.write_privileged', 'audit.read', 'jobs.read', 'settings.write'],
};
const ORG: Record<OrgRole, string[]> = {
owner: ['org.read', 'org.members.read', 'org.members.invite'],
admin: ['org.read', 'org.members.read', 'org.members.invite'],
member: ['org.read'],
};
/** Module dürfen weitere Rechte registrieren (siehe KcModule.permissions). */
const extra: { staff: Partial<Record<StaffRole, string[]>>; org: Partial<Record<OrgRole, string[]>> } = { staff: {}, org: {} };
export function registerPermissions(p: { staff?: Partial<Record<StaffRole, string[]>>; org?: Partial<Record<OrgRole, string[]>> }): void {
for (const [r, l] of Object.entries(p.staff ?? {})) extra.staff[r as StaffRole] = [...(extra.staff[r as StaffRole] ?? []), ...l];
for (const [r, l] of Object.entries(p.org ?? {})) extra.org[r as OrgRole] = [...(extra.org[r as OrgRole] ?? []), ...l];
}
export interface Principal {
userId: string;
kind: 'customer' | 'staff';
staffRole: StaffRole | null;
memberships: { orgId: string; role: OrgRole }[];
}
export function staffPermissions(role: StaffRole | null): string[] {
return role ? [...STAFF[role], ...(extra.staff[role] ?? [])] : [];
}
/** Globales Recht (nur Staff). */
export function can(p: Principal, permission: string): boolean {
return p.kind === 'staff' && staffPermissions(p.staffRole).includes(permission);
}
/** Objektbezogen: Staff mit globalem Recht ODER Mitglied der Organisation mit passender Org-Rolle. */
export function canInOrg(p: Principal, orgId: string, orgPermission: string, staffPermission: string): boolean {
if (can(p, staffPermission)) return true;
const m = p.memberships.find((x) => x.orgId === orgId);
return !!m && [...ORG[m.role], ...(extra.org[m.role] ?? [])].includes(orgPermission);
}

View file

@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { query } from '../../core/db.js';
import { verifyAuditChain } from '../../core/audit.js';
import { requirePermission } from '../../core/auth.js';
import type { KcModule } from '../../core/module.js';
export const auditModule: KcModule = {
name: 'audit',
register(app: FastifyInstance) {
app.get('/admin/audit', async (req) => {
requirePermission(req, 'audit.read');
const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query);
const rows = await query(
`SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json
FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?)
ORDER BY id DESC LIMIT ?`,
[q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit],
);
return rows.map((r) => ({ id: r.id, ts: r.ts, actorType: r.actor_type, actorId: r.actor_id, orgId: r.org_id, action: r.action, resourceType: r.resource_type, resourceId: r.resource_id, result: r.result, errorClass: r.error_class, correlationId: r.correlation_id, ip: r.ip, before: r.before_json, after: r.after_json }));
});
app.get('/admin/audit/verify', async (req) => {
requirePermission(req, 'audit.read');
return verifyAuditChain();
});
},
};

View file

@ -0,0 +1,176 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { verify } from '@node-rs/argon2';
import { randomUUID } from 'node:crypto';
import { isAbsolute, normalize } from 'node:path';
import { audit } from '../../core/audit.js';
import { one, query, run } from '../../core/db.js';
import { encrypt } from '../../core/crypto.js';
import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from '../../ops/settings.js';
import { loadTargets, testTarget } from '../../ops/targets.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
const requestDir = () => process.env.BACKUP_REQUEST_DIR ?? '/var/lib/kundencenter/requests';
const FILES = { backup: 'backup-run', 'restore-test': 'backup-restore-test' } as const;
const hours = (iso?: string | null) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
async function readState() {
let st: any = null;
try { st = JSON.parse(await readFile(statusFile(), 'utf8')); } catch { /* nicht eingerichtet oder noch nie gelaufen */ }
const envRemotes = (process.env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const dbTargets = await query('SELECT name, type, enabled, last_test_ok FROM backup_targets ORDER BY name').catch(() => []);
const remotes = [...dbTargets.filter((t) => t.enabled).map((t) => t.name as string), ...envRemotes];
const run = st?.lastRun ?? null; const test = st?.lastRestoreTest ?? null;
const pending = ['backup', 'restore-test'].filter((a) => existsSync(join(requestDir(), FILES[a as keyof typeof FILES])));
return {
configured: !!process.env.BACKUP_AGE_RECIPIENT || !!st || (await passwordMeta().catch(() => ({ set: false }))).set,
running: st?.running ?? null, pendingRequests: pending,
lastRun: run, lastRestoreTest: test, history: st?.history ?? [],
stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10,
schedule: { backup: 'täglich gegen 02:30 Uhr', restoreTest: 'sonntags gegen 04:30 Uhr' },
retention: { daily: Number(process.env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(process.env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(process.env.BACKUP_KEEP_MONTHLY ?? 12) },
localDir: process.env.BACKUP_DIR ?? '/var/backups/kundencenter', remotes, hasExternalTarget: remotes.length > 0, encryption: (await passwordMeta().catch(() => ({ set: false }))).set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none',
};
}
export const backupModule: KcModule = {
name: 'backup',
permissions: { staff: { admin: ['backup.read', 'backup.run'], superadmin: ['backup.read', 'backup.run', 'backup.secrets'] } },
register(app: FastifyInstance) {
app.get('/admin/backup', async (req) => { requirePermission(req, 'backup.read'); return readState(); });
// ---- Passwort und Ziele (nur Superadministratoren dürfen ändern) ------------------
const targetView = (r: any) => {
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json;
return { id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, config: cfg, hasSecret: !!r.secrets_enc, hostKeyPinned: !!r.host_key, hostKeyFingerprint: cfg.hostKeyFingerprint ?? null,
lastTestAt: r.last_test_at, lastTestOk: r.last_test_ok === null ? null : !!r.last_test_ok, lastTestError: r.last_test_error };
};
app.get('/admin/backup/settings', async (req) => {
requirePermission(req, 'backup.read');
const pw = await passwordMeta();
const targets = (await query('SELECT * FROM backup_targets ORDER BY name')).map(targetView);
return { encryption: { mode: pw.set ? 'password' : process.env.BACKUP_AGE_RECIPIENT ? 'age' : 'none', passwordSet: pw.set, version: pw.version, updatedAt: pw.updatedAt, minLength: MIN_PASSWORD }, targets };
});
/** Backup-Passwort setzen/ändern: mindestens 11 Zeichen, Wiederholung, Bestätigung mit dem eigenen Anmeldepasswort. Der Wert wird nie angezeigt oder protokolliert. */
app.put('/admin/backup/password', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const b = z.object({ password: z.string().max(200), repeat: z.string().max(200), currentPassword: z.string().max(200) }).parse(req.body);
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
if (!u?.password_hash || !(await verify(u.password_hash, b.currentPassword).catch(() => false))) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', result: 'denied', errorClass: 'reauth_failed', correlationId: req.correlationId, ip: clientIp(req) });
throw forbidden('Ihr Anmeldepasswort ist falsch.', 'INVALID_CREDENTIALS');
}
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein.', 'PASSWORD_MISMATCH');
const problem = passwordProblem(b.password); if (problem) throw badRequest(problem, 'WEAK_PASSWORD');
const version = await setBackupPassword(b.password, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.password.change', resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req), after: { version } });
return { version };
});
const HOST = /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$|^[0-9a-fA-F:]+$/;
const safeHost = (h: string) => HOST.test(h) && !/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$|metadata)/i.test(h);
const safeRel = (p: string) => !p.split('/').includes('..') && !/[\0\r\n]/.test(p);
const FORBIDDEN = ['/etc', '/proc', '/sys', '/dev', '/boot', '/usr', '/bin', '/sbin', '/lib', '/root', '/var/lib/kundencenter', '/srv/kundencenter'];
const str = (n: number) => z.string().trim().min(1).max(n);
const common = { name: str(100), path: z.string().trim().max(400).default('') };
const targetSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('sftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(22), user: str(100), authType: z.enum(['password', 'key']), password: z.string().max(500).optional(), privateKey: z.string().max(10000).optional(), keyPassphrase: z.string().max(500).optional() }),
z.object({ type: z.literal('ftp'), ...common, host: str(253), port: z.number().int().min(1).max(65535).default(21), user: str(100), tls: z.enum(['none', 'explicit', 'implicit']).default('explicit'), password: z.string().min(1).max(500) }),
z.object({ type: z.literal('gdrive'), ...common, scope: z.enum(['drive.file', 'drive']).default('drive.file'), token: z.string().min(10).max(8000), clientId: z.string().max(300).optional(), clientSecret: z.string().max(300).optional() }),
z.object({ type: z.literal('local'), ...common }),
]);
type NewTarget = z.infer<typeof targetSchema>;
function split(t: NewTarget) {
const cfg: Record<string, unknown> = {}; const sec: Record<string, string> = {};
if (t.type === 'sftp') {
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.authType = t.authType;
if (t.authType === 'password') { if (!t.password) throw badRequest('Bitte ein Passwort angeben.', 'SECRET_MISSING'); sec.password = t.password; }
else { if (!t.privateKey || !/PRIVATE KEY/.test(t.privateKey)) throw badRequest('Bitte den privaten Schlüssel (PEM) einfügen.', 'SECRET_MISSING'); sec.privateKey = t.privateKey; if (t.keyPassphrase) sec.keyPassphrase = t.keyPassphrase; }
} else if (t.type === 'ftp') {
if (!safeHost(t.host)) throw badRequest('Ungültiger oder nicht erlaubter Servername.', 'BAD_HOST');
cfg.host = t.host; cfg.port = t.port; cfg.user = t.user; cfg.tls = t.tls; sec.password = t.password;
} else if (t.type === 'gdrive') {
try { const j = JSON.parse(t.token); if (!j.access_token && !j.refresh_token) throw new Error(); } catch { throw badRequest('Der Token muss der JSON-Text aus "rclone authorize" sein.', 'BAD_TOKEN'); }
cfg.scope = t.scope; sec.token = t.token; if (t.clientId) sec.clientId = t.clientId; if (t.clientSecret) sec.clientSecret = t.clientSecret;
}
let path = t.path.replace(/\\/g, '/');
if (t.type === 'local') {
path = normalize(path);
if (!isAbsolute(path) || path === '/' || FORBIDDEN.some((f) => path === f || path.startsWith(f + '/'))) throw badRequest('Bitte einen absoluten Ordner angeben (z. B. ein eingehängtes Netzlaufwerk unter /mnt/...). Systemordner sind nicht erlaubt.', 'BAD_PATH');
} else if (!safeRel(path)) throw badRequest('Ungültiger Ordnerpfad.', 'BAD_PATH');
return { cfg, sec, path };
}
app.post('/admin/backup/targets', async (req) => {
const a = requirePermission(req, 'backup.secrets');
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();
await run('INSERT INTO backup_targets (id, name, type, path, config_json, secrets_enc) VALUES (?,?,?,?,?,?)', [id, b.name, b.type, path, JSON.stringify(cfg), Object.keys(sec).length ? encrypt(JSON.stringify(sec)) : null]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.create', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, type: b.type, path, config: cfg, secrets: Object.keys(sec) } });
return { id };
});
app.patch('/admin/backup/targets/:id', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ enabled: z.boolean().optional(), name: str(100).optional() }).parse(req.body);
const r = await one('SELECT * FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
if (b.name && b.name !== r.name && (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name]))) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS');
await run('UPDATE backup_targets SET enabled = COALESCE(?, enabled), name = COALESCE(?, name) WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.name ?? null, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.update', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!r.enabled, name: r.name }, after: b });
return { status: 'ok' };
});
app.delete('/admin/backup/targets/:id', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT name, type FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
await run('DELETE FROM backup_targets WHERE id = ?', [id]); // bereits abgelegte Sicherungen am Ziel bleiben unberührt
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.delete', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { name: r.name, type: r.type } });
return { status: 'ok' };
});
/** Verbindungstest (legt Ordner an, schreibt und löscht eine Testdatei). Bei SFTP wird der Server-Schlüssel beim ersten Erfolg gemerkt. */
app.post('/admin/backup/targets/:id/test', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const t = (await loadTargets(false)).find((x) => x.id === id); if (!t) throw notFound();
const res = await testTarget(t);
if (res.ok && res.hostKey) { const cfg = { ...t.config, hostKeyFingerprint: res.fingerprint }; await run('UPDATE backup_targets SET host_key = ?, config_json = ? WHERE id = ?', [res.hostKey, JSON.stringify(cfg), id]); }
await run('UPDATE backup_targets SET last_test_at = UTC_TIMESTAMP(3), last_test_ok = ?, last_test_error = ? WHERE id = ?', [res.ok ? 1 : 0, res.ok ? null : (res.error ?? '').slice(0, 400), id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.test', resourceType: 'backup_target', resourceId: id, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'test_failed', correlationId: req.correlationId, ip: clientIp(req), after: { fingerprint: res.fingerprint } });
return { ok: res.ok, error: res.error ?? null, fingerprint: res.fingerprint ?? null, newHostKey: !!res.hostKey };
});
app.post('/admin/backup/targets/:id/reset-hostkey', async (req) => {
const a = requirePermission(req, 'backup.secrets');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT config_json FROM backup_targets WHERE id = ?', [id]); if (!r) throw notFound();
const cfg = typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json; delete cfg.hostKeyFingerprint;
await run('UPDATE backup_targets SET host_key = NULL, config_json = ?, last_test_ok = NULL WHERE id = ?', [JSON.stringify(cfg), id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'backup.target.hostkey_reset', resourceType: 'backup_target', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
/**
* Sicherung oder Wiederherstellungstest anfordern. Die Oberfläche startet nichts selbst: Sie legt nur eine Anfragedatei ab,
* die ein systemd-Pfadauslöser als root abarbeitet (feste Aktionen, keine Eingaben).
*/
app.post('/admin/backup/run', async (req, reply) => {
const a = requirePermission(req, 'backup.run');
const { action } = z.object({ action: z.enum(['backup', 'restore-test']) }).parse(req.body);
const st = await readState();
if (st.running) throw conflict('Es läuft bereits ein Vorgang. Bitte warten.', 'BACKUP_RUNNING');
if (st.pendingRequests.length) throw conflict('Es liegt bereits eine Anfrage vor. Bitte einen Moment warten.', 'BACKUP_PENDING');
await mkdir(requestDir(), { recursive: true });
await writeFile(join(requestDir(), FILES[action]), `${new Date().toISOString()}\n`, { mode: 0o644 });
await audit({ actorType: 'user', actorId: a.user.id, action: `backup.request.${action}`, resourceType: 'backup', correlationId: req.correlationId, ip: clientIp(req) });
return reply.code(202).send({ status: 'requested' });
});
},
};

View file

@ -0,0 +1,273 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { calculatePrice } from '@kc/platform/pricing';
import { getConnector, loadInstance } from '@kc/connectors';
import { ConnectorError } from '@kc/connector-sdk';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const int = (max: number) => z.number().int().min(0).max(max);
const versionSchema = z.object({
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
provisioning: z.record(z.string(), z.unknown()).default({}),
});
type VersionIn = z.infer<typeof versionSchema>;
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
const ACTIONS = z.array(z.enum(CUSTOMER_ACTIONS));
/** Fachregeln für Laufzeiten/Preise je Abrechnungsintervall. */
function checkTerms(v: VersionIn): number {
if (v.billingInterval === 'once') {
if (v.recurringCents > 0) throw badRequest('Einmalprodukte haben keinen wiederkehrenden Preis', 'BAD_TERMS');
if (v.termMonths > 0 || v.renewal === 'auto') throw badRequest('Einmalprodukte haben keine Laufzeit und keine Verlängerung', 'BAD_TERMS');
return 0;
}
const per = v.billingInterval === 'monthly' ? 1 : 12;
const renewalMonths = v.renewal === 'auto' ? (v.renewalTermMonths ?? per) : 0;
if (v.renewal === 'auto' && renewalMonths < 1) throw badRequest('Bei automatischer Verlängerung ist eine Verlängerungslaufzeit nötig', 'BAD_TERMS');
if (v.termMonths > 0 && v.termMonths % per !== 0) throw badRequest(`Die Laufzeit muss ein Vielfaches des Abrechnungsintervalls (${per} Monat${per > 1 ? 'e' : ''}) sein`, 'BAD_TERMS');
return renewalMonths;
}
async function checkConnector(instanceId: string | null | undefined, provisioning: Record<string, unknown>, forActivation = false): Promise<void> {
if (!instanceId) { if (Object.keys(provisioning).length) throw badRequest('Provisionierungsparameter ohne Verbindung', 'BAD_PROVISIONING'); return; }
const inst = await one('SELECT connector_key, capabilities_json FROM connector_instances WHERE id = ?', [instanceId]);
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
const c = getConnector(inst.connector_key);
const msg = c.validateProvisioning?.(provisioning) ?? null;
if (msg) throw badRequest(`Provisionierung ungültig: ${msg}`, 'BAD_PROVISIONING');
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
if (caps.length && !caps.includes('lifecycle.create')) throw badRequest('Diese Verbindung kann aktuell keine Objekte anlegen (Zugangsdaten mit Schreibrechten prüfen)', 'NO_CREATE_CAPABILITY');
// Edition (Schlüssel-Präfix) und festes Ablaufdatum: ein aktives Produkt darf sie nur versprechen, wenn der Anbieter sie nachweislich umsetzt.
if (forActivation) {
if (provisioning.keyPrefix && !caps.includes('license.key_prefix')) throw badRequest('Produkte mit Edition (Schlüssel-Präfix) können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt. Als Entwurf ist es gespeichert.', 'NEEDS_LICENSE_EXTENSION');
if (provisioning.validityDays && !caps.includes('license.expiry')) throw badRequest('Produkte mit festem Ablaufdatum können erst aktiviert werden, wenn das Lizenzsystem diese Erweiterung unterstützt.', 'NEEDS_LICENSE_EXTENSION');
}
}
async function insertVersion(c: Parameters<typeof run>[2], productId: string, v: VersionIn, by: string): Promise<string> {
const tax = await one('SELECT id, rate_bp, active FROM tax_rates WHERE id = ?', [v.taxRateId], c);
if (!tax || !tax.active) throw badRequest('Steuersatz nicht gefunden', 'BAD_TAX');
const renewalMonths = checkTerms(v);
const last = await one('SELECT COALESCE(MAX(version), 0) AS n FROM product_versions WHERE product_id = ?', [productId], c);
const id = randomUUID();
await run(
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
return id;
}
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
await checkConnector(b.connectorInstanceId, b.version.provisioning, b.status === 'active');
const id = randomUUID();
await tx(async (c) => {
await run('INSERT INTO products (id, sku, category, status, connector_instance_id, external_ref, orderable_by_customer, requires_approval, customer_actions) VALUES (?,?,?,?,?,?,?,?,?)',
[id, b.sku, b.category, b.status, b.connectorInstanceId ?? null, b.externalRef ?? null, b.orderableByCustomer ? 1 : 0, b.requiresApproval ? 1 : 0, JSON.stringify(b.customerActions)], c);
await insertVersion(c, id, b.version, actorId);
});
return id;
}
// ---- Produktpakete (Vorlagen, z. B. Editionen einer Software) --------------------
const bundleSchema = z.object({
key: z.string().regex(/^[a-z0-9-]{2,40}$/), name: z.string().max(200), description: z.string().max(2000).optional(), source: z.string().max(200).optional(),
products: z.array(z.object({
sku: z.string().regex(/^[A-Za-z0-9._-]{2,50}$/), name: z.string().max(200), description: z.string().max(2000).optional(), category: z.enum(['hosting', 'license', 'addon', 'service']),
priceBasis: z.enum(['net', 'gross']), setupCents: int(100_000_00), recurringCents: int(100_000_00), taxBp: int(10000), interval: z.enum(['once', 'monthly', 'yearly']),
termMonths: int(120), renewal: z.enum(['auto', 'none']), renewalTermMonths: int(120), noticeDays: int(365), provisioning: z.record(z.string(), z.unknown()),
orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]),
})).min(1).max(50),
});
type Bundle = z.infer<typeof bundleSchema>;
const bundleDir = fileURLToPath(new URL('../../../../../bundles/', import.meta.url));
function loadBundles(): Bundle[] {
if (!existsSync(bundleDir)) return [];
const out: Bundle[] = [];
for (const f of readdirSync(bundleDir).filter((n) => n.endsWith('.json')).sort()) {
const r = bundleSchema.safeParse(JSON.parse(readFileSync(join(bundleDir, f), 'utf8')));
if (r.success) out.push(r.data);
}
return out;
}
const versionView = (r: any) => ({
id: r.vid ?? r.id, version: r.version, name: r.vname ?? r.name, description: r.description, taxBp: r.tax_bp, priceBasis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, currency: r.currency,
billingInterval: r.billing_interval, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days,
});
const productSelect = `SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.description, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days, v.provisioning_json, i.name AS connector_name
FROM products p LEFT JOIN product_versions v ON v.id = p.current_version_id LEFT JOIN connector_instances i ON i.id = p.connector_instance_id`;
const j = (v: unknown, d: unknown) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
const productView = (r: any) => ({
id: r.id, sku: r.sku, category: r.category, status: r.status, connectorInstanceId: r.connector_instance_id, externalRef: r.external_ref ?? null, connectorName: r.connector_name, orderableByCustomer: !!r.orderable_by_customer, requiresApproval: !!r.requires_approval,
customerActions: j(r.customer_actions, []), provisioning: j(r.provisioning_json, {}), current: r.vid ? versionView(r) : null,
});
export const catalogModule: KcModule = {
name: 'catalog',
permissions: { staff: { support: ['products.read'], accounting: ['products.read'], admin: ['products.read', 'products.write'], superadmin: ['products.read', 'products.write'] } },
register(app: FastifyInstance) {
app.get('/admin/tax-rates', async (req) => {
requirePermission(req, 'products.read');
return (await query('SELECT id, name, rate_bp FROM tax_rates WHERE active = 1 ORDER BY rate_bp DESC')).map((t) => ({ id: t.id, name: t.name, rateBp: t.rate_bp }));
});
app.get('/admin/products', async (req) => {
requirePermission(req, 'products.read');
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
});
app.get('/admin/products/:id', async (req) => {
requirePermission(req, 'products.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
if (!r) throw notFound();
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
return { ...productView(r), versions: versions.map(versionView) };
});
app.post('/admin/products', async (req) => {
const a = requirePermission(req, 'products.write');
const b = z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/, 'Nur Buchstaben, Ziffern, Punkt, Unterstrich und Bindestrich'), category: z.enum(['hosting', 'license', 'addon', 'service']),
connectorInstanceId: z.string().uuid().nullable().optional(), externalRef: z.string().trim().max(100).optional(), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), status: z.enum(['draft', 'active']).default('draft'), version: versionSchema }).parse(req.body);
if (b.externalRef && !b.connectorInstanceId) throw badRequest('Herkunft ohne Verbindung', 'BAD_CONNECTOR');
const id = await createProduct(b, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.sku, importedFrom: b.externalRef, status: b.status, version: b.version.name } });
return { id };
});
/** Preis-/Vertragsänderung = neue unveränderliche Version. Bestehende Bestellungen und Verträge behalten ihren Snapshot. */
app.post('/admin/products/:id/versions', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const v = versionSchema.parse(req.body);
const p = await one('SELECT id, connector_instance_id, status FROM products WHERE id = ?', [id]);
if (!p) throw notFound();
await checkConnector(p.connector_instance_id, v.provisioning, p.status === 'active');
const vid = await tx((c) => insertVersion(c, id, v, a.user.id));
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.version', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { versionId: vid, setupCents: v.setupCents, recurringCents: v.recurringCents } });
return { versionId: vid };
});
app.patch('/admin/products/:id', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ status: z.enum(['draft', 'active', 'retired']).optional(), orderableByCustomer: z.boolean().optional(), requiresApproval: z.boolean().optional(), customerActions: ACTIONS.optional(), connectorInstanceId: z.string().uuid().nullable().optional() }).parse(req.body);
const before = await one(`${productSelect} WHERE p.id = ?`, [id]);
if (!before) throw notFound();
if (b.status === 'active' && !before.vid) throw badRequest('Ohne Version nicht aktivierbar');
if (b.status === 'active') await checkConnector(before.connector_instance_id, j(before.provisioning_json, {}) as Record<string, unknown>, true);
if (b.connectorInstanceId !== undefined) await checkConnector(b.connectorInstanceId, j(before.provisioning_json, {}) as Record<string, unknown>);
await run('UPDATE products SET status = COALESCE(?, status), orderable_by_customer = COALESCE(?, orderable_by_customer), requires_approval = COALESCE(?, requires_approval), customer_actions = COALESCE(?, customer_actions), connector_instance_id = ? WHERE id = ?',
[b.status ?? null, b.orderableByCustomer === undefined ? null : b.orderableByCustomer ? 1 : 0, b.requiresApproval === undefined ? null : b.requiresApproval ? 1 : 0, b.customerActions ? JSON.stringify(b.customerActions) : null, b.connectorInstanceId === undefined ? before.connector_instance_id : b.connectorInstanceId, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.update', resourceType: 'product', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: before.status, orderableByCustomer: !!before.orderable_by_customer, requiresApproval: !!before.requires_approval }, after: b });
return { status: 'ok' };
});
/**
* Übernahme: Angebote/Programme eines Anbieters live auslesen (Verbindung, Zugangsdaten bleiben serverseitig).
* Zeigt je Eintrag, wie viele Produkte bereits daraus angelegt wurden.
*/
app.get('/admin/connectors/:id/catalog', async (req) => {
requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
try {
const { connector, ctx } = await loadInstance(id, req.correlationId);
if (!connector.listCatalog || !(await connector.capabilities(ctx)).includes('catalog.list')) throw badRequest('Diese Verbindung unterstützt keine Produktübernahme', 'NO_CATALOG');
const items = await connector.listCatalog(ctx);
const counts = await query('SELECT external_ref, COUNT(*) AS n FROM products WHERE connector_instance_id = ? AND external_ref IS NOT NULL GROUP BY external_ref', [id]);
const byRef = new Map(counts.map((c) => [c.external_ref as string, Number(c.n)]));
return items.map((i) => ({ ...i, importedProducts: byRef.get(i.externalRef) ?? 0 }));
} catch (e) {
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`);
throw e;
}
});
/** Vorlagenpakete (Dateien in /bundles): Vorschau mit Preisen, Laufzeiten und Provisionierung. */
app.get('/admin/product-bundles', async (req) => {
requirePermission(req, 'products.write');
const taken = new Set((await query('SELECT sku FROM products')).map((r) => r.sku as string));
return loadBundles().map((b) => ({ ...b, products: b.products.map((p) => ({ ...p, exists: taken.has(p.sku) })) }));
});
/** Importiert ausgewählte Produkte eines Pakets als Entwürfe und verknüpft sie mit Verbindung und Anbieter-Programm. */
app.post('/admin/product-bundles/:key/import', async (req) => {
const a = requirePermission(req, 'products.write');
const { key } = z.object({ key: z.string().max(40) }).parse(req.params);
const b = z.object({ connectorInstanceId: z.string().uuid(), programRef: z.string().trim().min(1).max(100), skus: z.array(z.string()).min(1).max(50) }).parse(req.body);
const bundle = loadBundles().find((x) => x.key === key);
if (!bundle) throw notFound('Paket nicht gefunden');
const inst = await one('SELECT connector_key FROM connector_instances WHERE id = ?', [b.connectorInstanceId]);
if (!inst) throw badRequest('Verbindung nicht gefunden', 'BAD_CONNECTOR');
const tax = await query('SELECT id, rate_bp FROM tax_rates WHERE active = 1');
const created: { sku: string; id: string }[] = []; const skipped: { sku: string; reason: string }[] = [];
for (const sku of b.skus) {
const p = bundle.products.find((x) => x.sku === sku);
if (!p) { skipped.push({ sku, reason: 'nicht im Paket' }); continue; }
const t = tax.find((x) => Number(x.rate_bp) === p.taxBp);
if (!t) { skipped.push({ sku, reason: `Steuersatz ${p.taxBp / 100} % nicht angelegt` }); continue; }
try {
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
created.push({ sku, id });
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.bundle.import', resourceType: 'product', connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { bundle: key, programRef: b.programRef, created: created.map((c) => c.sku), skipped } });
return { created, skipped };
});
/**
* Neuen Hosting-Tarif beim Anbieter anlegen UND als Produkt definieren. Wirkt sofort beim Anbieter (Tarif entsteht dort):
* Name muss frei sein, Größen in GB, "unbegrenzt" nur wenn die Instanz es kennt. Danach entsteht das Produkt (Entwurf oder aktiv).
*/
app.post('/admin/connectors/:id/hosting-plans', async (req) => {
const a = requirePermission(req, 'products.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const lim = z.number().min(0).max(1_000_000).nullable().optional();
const b = z.object({
plan: z.object({ name: z.string().trim().min(1).max(100), limits: z.object({ diskSpaceGb: lim, trafficGb: lim, domains: lim, subdomains: lim, emailAccounts: lim, emailAddresses: lim, emailForwardings: lim, databases: lim, ftpUsers: lim, scheduledTasks: lim }).default({}), permissions: z.record(z.string(), z.boolean()).optional() }),
product: z.object({ sku: z.string().trim().regex(/^[A-Za-z0-9._-]{2,50}$/), status: z.enum(['draft', 'active']).default('draft'), orderableByCustomer: z.boolean().default(false), requiresApproval: z.boolean().default(true), customerActions: ACTIONS.default([]), version: versionSchema.omit({ provisioning: true }) }),
}).parse(req.body);
const inst = await one('SELECT id, connector_key, capabilities_json FROM connector_instances WHERE id = ?', [id]);
if (!inst) throw notFound();
const caps: string[] = inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : [];
if (!caps.includes('catalog.write')) throw badRequest('Diese Verbindung kann keine Tarife anlegen', 'NO_CATALOG_WRITE');
if (await one('SELECT 1 AS x FROM products WHERE sku = ?', [b.product.sku])) throw conflict('Artikelnummer bereits vergeben', 'SKU_EXISTS');
let item;
try { const { connector, ctx } = await loadInstance(id, req.correlationId); item = await connector.createCatalogItem!(ctx, b.plan); }
catch (e) { if (e instanceof ConnectorError) throw new AppError(e.code === 'CONFLICT' ? 409 : e.code === 'INVALID_INPUT' ? 400 : 502, e.code === 'CONFLICT' ? 'PLAN_EXISTS' : 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' || e.code === 'CONFLICT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; }
await audit({ actorType: 'user', actorId: a.user.id, action: 'catalog.plan.create', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.plan.name, ref: item.externalRef, limits: b.plan.limits } });
try {
const productId = await createProduct({ sku: b.product.sku, category: item.category, connectorInstanceId: id, externalRef: item.externalRef, orderableByCustomer: b.product.orderableByCustomer, requiresApproval: b.product.requiresApproval, customerActions: b.product.customerActions, status: b.product.status,
version: { ...b.product.version, provisioning: item.provisioning } }, a.user.id);
await audit({ actorType: 'user', actorId: a.user.id, action: 'product.create', resourceType: 'product', resourceId: productId, correlationId: req.correlationId, ip: clientIp(req), after: { sku: b.product.sku, importedFrom: item.externalRef, viaPlanCreate: true } });
return { productId, plan: { ref: item.externalRef, name: item.name, features: item.features } };
} catch (e) {
// Der Tarif existiert beim Anbieter bereits: nicht erneut anlegen, sondern über "Übernehmen" als Produkt anlegen
throw new AppError(e instanceof AppError ? e.status : 500, 'PRODUCT_AFTER_PLAN_FAILED', `Der Tarif „${item.name}“ wurde beim Anbieter angelegt, das Produkt konnte aber nicht angelegt werden${e instanceof AppError ? `: ${e.message}` : ''}. Bitte den Tarif über „Aus Verbindung übernehmen“ als Produkt übernehmen.`);
}
});
/** Katalog für Kunden: nur aktive, bestellbare Produkte, Preise für die jeweilige Organisation (Netto/Brutto). */
app.get('/catalog', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid() }).parse(req.query);
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
return rows.map((r) => {
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
});
});
},
};

View file

@ -0,0 +1,98 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { connect as tlsConnect } from 'node:tls';
import { encryptSecrets, getConnector, listConnectors } from '@kc/connectors';
import { one, query, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { AppError, badRequest, conflict, notFound } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const parseErr = (v: unknown): { tech: string; hint: string } | null => { if (!v) return null; try { const o = JSON.parse(String(v)); return { tech: String(o.tech ?? ''), hint: String(o.hint ?? '') }; } catch { return { tech: String(v), hint: '' }; } };
const pub = (r: any) => ({
id: r.id, connector: r.connector_key, name: r.name, enabled: !!r.enabled, syncIntervalSec: r.sync_interval_sec, health: r.health, healthMessage: r.health_message,
capabilities: r.capabilities_json ?? [], lastOkAt: r.last_ok_at, lastError: parseErr(r.last_error), lastErrorAt: r.last_error_at ?? null, lastSyncAt: r.last_sync_at, config: r.config_json, hasSecrets: !!r.secrets_enc, resources: Number(r.resources ?? 0),
});
/** Trennt Eingabefelder in Konfiguration und Geheimnisse anhand der Felddefinition des Connectors. */
function split(key: string, input: Record<string, string>) {
const fields = getConnector(key).configFields; const config: Record<string, string> = {}; const secrets: Record<string, string> = {};
for (const f of fields) { const v = input[f.name]?.trim(); if (v) (f.secret ? secrets : config)[f.name] = v; }
const missing = fields.filter((f) => f.required && !config[f.name] && !secrets[f.name]).map((f) => f.label);
return { config, secrets, missing };
}
export const connectorsModule: KcModule = {
name: 'connectors',
permissions: { staff: { admin: ['connectors.read'], superadmin: ['connectors.read', 'connectors.write'] } },
register(app: FastifyInstance) {
app.get('/admin/connector-types', async (req) => {
requirePermission(req, 'connectors.read');
return listConnectors().map((c) => ({ key: c.key, name: c.displayName, fields: c.configFields.map((f) => ({ name: f.name, label: f.label, secret: !!f.secret, required: !!f.required, placeholder: f.placeholder ?? '', help: f.help ?? '', advanced: !!f.advanced, options: f.options ?? null })) }));
});
/**
* Zertifikat-Fingerabdruck eines Servers holen (für selbstsignierte Zertifikate). Es wird nur ein TLS-Handshake durchgeführt und
* das Zertifikat gelesen, keine Anfrage gesendet. Loopback/Link-Local sind gesperrt.
*/
app.post('/admin/connector-tls-check', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
requirePermission(req, 'connectors.write');
const { url } = z.object({ url: z.string().max(300) }).parse(req.body);
let u: URL; try { u = new URL(url); } catch { throw badRequest('Ungültige Adresse', 'BAD_URL'); }
if (u.protocol !== 'https:') throw badRequest('Bitte eine Adresse mit https:// angeben', 'BAD_URL');
const host = u.hostname.replace(/^\[|\]$/g, ''); if (/^(localhost|0\.0\.0\.0|127\.|169\.254\.|::1?$)/i.test(host)) throw badRequest('Diese Adresse ist nicht erlaubt', 'BAD_HOST');
const port = Number(u.port || 443);
return new Promise((resolve, reject) => {
const sock = tlsConnect({ host, port, servername: host, rejectUnauthorized: false, timeout: 8000 }, () => {
const c = sock.getPeerCertificate(); const trusted = sock.authorized; sock.end();
if (!c || !c.fingerprint256) return reject(new AppError(502, 'NO_CERT', 'Der Server hat kein Zertifikat geliefert.'));
resolve({ fingerprint: c.fingerprint256, subject: c.subject?.CN ?? null, issuer: c.issuer?.CN ?? null, validTo: c.valid_to ?? null, trusted, selfSigned: c.issuer?.CN === c.subject?.CN && !trusted });
});
sock.on('timeout', () => { sock.destroy(); reject(new AppError(502, 'TIMEOUT', 'Der Server hat nicht rechtzeitig geantwortet.')); });
sock.on('error', () => reject(new AppError(502, 'UNREACHABLE', 'Der Server ist nicht erreichbar (Adresse und Port prüfen).')));
});
});
app.get('/admin/connectors', async (req) => {
requirePermission(req, 'connectors.read');
return (await query('SELECT i.*, (SELECT COUNT(*) FROM resources r WHERE r.instance_id = i.id) AS resources FROM connector_instances i ORDER BY i.name')).map(pub);
});
app.post('/admin/connectors', async (req) => {
const a = requirePermission(req, 'connectors.write'); // Secrets: nur Superadmin
const b = z.object({ connector: z.string().max(50), name: z.string().trim().min(1).max(100), values: z.record(z.string(), z.string().max(500)), syncIntervalSec: z.number().int().min(60).max(86400).default(300) }).parse(req.body);
try { getConnector(b.connector); } catch { throw badRequest('Unbekannter Connector'); }
const { config, secrets, missing } = split(b.connector, b.values);
if (missing.length) throw badRequest(`Pflichtfelder fehlen: ${missing.join(', ')}`);
if (await one('SELECT 1 AS x FROM connector_instances WHERE name = ?', [b.name])) throw conflict('Name bereits vergeben');
const id = randomUUID();
await run('INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc, sync_interval_sec) VALUES (?,?,?,?,?,?)', [id, b.connector, b.name, JSON.stringify(config), encryptSecrets(secrets), b.syncIntervalSec]);
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:initial`, correlationId: req.correlationId });
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.create', resourceType: 'connector', resourceId: id, connector: b.connector, correlationId: req.correlationId, ip: clientIp(req), after: { name: b.name, config, secrets: Object.keys(secrets) } });
return { id };
});
app.patch('/admin/connectors/:id', async (req) => {
const a = requirePermission(req, 'connectors.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ enabled: z.boolean().optional(), syncIntervalSec: z.number().int().min(60).max(86400).optional(), values: z.record(z.string(), z.string().max(500)).optional() }).parse(req.body);
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [id]);
if (!inst) throw notFound();
let config = inst.config_json as Record<string, string>; let secretsEnc = inst.secrets_enc as string | null;
if (b.values) {
// Leere Werte behalten den bisherigen Wert; Geheimnisse werden nur ersetzt, wenn neu angegeben.
const { config: c2, secrets } = split(inst.connector_key, b.values);
config = { ...config, ...c2 };
if (Object.keys(secrets).length) { const { decrypt } = await import('../../core/crypto.js'); const old = inst.secrets_enc ? JSON.parse(decrypt(inst.secrets_enc)) : {}; secretsEnc = encryptSecrets({ ...old, ...secrets }); }
}
await run('UPDATE connector_instances SET enabled = COALESCE(?, enabled), sync_interval_sec = COALESCE(?, sync_interval_sec), config_json = ?, secrets_enc = ? WHERE id = ?', [b.enabled === undefined ? null : b.enabled ? 1 : 0, b.syncIntervalSec ?? null, JSON.stringify(config), secretsEnc, id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.update', resourceType: 'connector', resourceId: id, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { enabled: !!inst.enabled, config: inst.config_json }, after: { enabled: b.enabled, syncIntervalSec: b.syncIntervalSec, config, secretsChanged: !!b.values } });
return { status: 'ok' };
});
app.post('/admin/connectors/:id/sync', async (req) => {
const a = requirePermission(req, 'connectors.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!(await one('SELECT 1 AS x FROM connector_instances WHERE id = ?', [id]))) throw notFound();
await enqueue('connector.sync', { instanceId: id }, { idempotencyKey: `sync:${id}:manual:${Date.now()}`, correlationId: req.correlationId });
await audit({ actorType: 'user', actorId: a.user.id, action: 'connector.sync.request', resourceType: 'connector', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'queued' };
});
},
};

View file

@ -0,0 +1,264 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { clientIp, requireAuth, requirePermission } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { canInOrg } from '../../core/policy.js';
import { createInvitedUser, inviteLink, mailInvite } from '../../core/accounts.js';
import { enqueue } from '../../core/jobs.js';
import { calculatePrice } from '@kc/platform/pricing';
import { addMonths, consumerTerms } from '@kc/platform/contractterms';
import { getConnector, loadInstance, syncInstance } from '@kc/connectors';
import { ConnectorError, type ImportableCustomer } from '@kc/connector-sdk';
import { AppError } from '../../core/errors.js';
import type { KcModule } from '../../core/module.js';
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
const opt = (n: number) => z.string().trim().max(n).optional().transform((v) => (v ? v : null));
const billing = z.object({
company: opt(200), contactName: opt(150), street: opt(200), zip: opt(20), city: opt(100),
country: z.string().length(2).toUpperCase().default('DE'), vatId: opt(30), billingEmail: opt(254), phone: opt(50),
});
type BillingIn = z.infer<typeof billing>;
/** Fachregeln je Kundenart: Privatkunde ohne Firma/USt-IdNr., Geschäftskunde mit Firmenname. */
function applyTypeRules(type: 'private' | 'business', name: string, b: Partial<BillingIn>, opts: { partial?: boolean } = {}): void {
if (type === 'private') {
if (b.company || b.vatId) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr.', 'PRIVATE_NO_COMPANY');
} else {
if (!opts.partial && !(b.company ?? name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY');
if (b.vatId && !/^[A-Z]{2}[A-Z0-9]{2,12}$/.test(b.vatId.replace(/[\s.-]/g, '').toUpperCase())) throw badRequest('USt-IdNr. hat ein ungültiges Format (z. B. DE123456789)', 'INVALID_VAT_ID');
}
}
const normVat = (v: string | null | undefined) => (v ? v.replace(/[\s.-]/g, '').toUpperCase() : v);
async function nextCustomerNumber(c: Parameters<typeof one>[2]): Promise<string> {
await run('UPDATE customer_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE id = 1', [], c as never);
const r = await one('SELECT LAST_INSERT_ID() AS n', [], c);
return `K-${r!.n}`;
}
export const customersModule: KcModule = {
name: 'customers',
register(app: FastifyInstance) {
// ---- Admin: Kunden -----------------------------------------------------
app.get('/admin/customers', async (req) => {
requirePermission(req, 'customers.read');
const q = z.object({ q: z.string().max(100).optional(), type: z.enum(['private', 'business']).optional() }).parse(req.query);
const rows = await query(
`SELECT o.id, o.customer_number, o.name, o.customer_type, o.status, o.created_at, b.city, b.billing_email,
(SELECT COUNT(*) FROM memberships m WHERE m.org_id = o.id) AS members
FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id
WHERE (? IS NULL OR o.name LIKE CONCAT('%', ?, '%') OR o.customer_number LIKE CONCAT('%', ?, '%') OR b.billing_email LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR o.customer_type = ?)
ORDER BY o.created_at DESC LIMIT 200`,
[q.q ?? null, q.q ?? null, q.q ?? null, q.q ?? null, q.type ?? null, q.type ?? null],
);
return rows.map((r) => ({ id: r.id, customerNumber: r.customer_number, name: r.name, customerType: r.customer_type, status: r.status, createdAt: r.created_at, city: r.city, billingEmail: r.billing_email, members: Number(r.members) }));
});
app.post('/admin/customers', async (req) => {
const a = requirePermission(req, 'customers.write');
const b = z.object({ type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200), owner: z.object({ email, name: z.string().trim().min(1).max(150).optional() }), billing: billing.prefault({}) }).parse(req.body);
b.billing.vatId = normVat(b.billing.vatId) ?? null;
applyTypeRules(b.type, b.name, b.billing);
const ownerName = b.owner.name ?? b.name; // Privatkunde: Ansprechpartner = Kunde selbst
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS');
const orgId = randomUUID();
const res = await tx(async (c) => {
const number = await nextCustomerNumber(c);
await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c);
const bp = b.billing;
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, vat_id, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?,?)',
[orgId, b.type === 'business' ? (bp.company ?? b.name) : null, b.type === 'private' ? b.name : bp.contactName, bp.street, bp.zip, bp.city, bp.country, bp.vatId, bp.billingEmail ?? b.owner.email, bp.phone], c);
const inv = await createInvitedUser(c, { email: b.owner.email, name: ownerName, kind: 'customer' });
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,\'owner\')', [orgId, inv.userId], c);
await enqueue('discord.notify', { event: 'customer.created', customerNumber: number }, { idempotencyKey: `customer.created:${orgId}`, correlationId: req.correlationId }, c);
return { number, inv };
});
const mail = await mailInvite(b.owner.email, ownerName, res.inv.token);
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.create', resourceType: 'organization', resourceId: orgId, correlationId: req.correlationId, ip: clientIp(req), after: { customerNumber: res.number, customerType: b.type, name: b.name, owner: b.owner.email } });
return { id: orgId, customerNumber: res.number, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(res.inv.token) };
});
app.get('/admin/customers/:id', async (req) => {
requirePermission(req, 'customers.read');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
return loadOrg(id);
});
app.patch('/admin/customers/:id', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
// Felder: nicht angegeben = unverändert, leer/null = löschen
const f = (n: number) => z.string().trim().max(n).nullable().optional();
const b = z.object({
name: z.string().trim().min(1).max(200).optional(), customerType: z.enum(['private', 'business']).optional(), status: z.enum(['active', 'suspended', 'closed']).optional(),
billing: z.object({ company: f(200), contactName: f(150), street: f(200), zip: f(20), city: f(100), country: z.string().length(2).toUpperCase().optional(), vatId: f(30), billingEmail: f(254), phone: f(50) }).optional(),
}).parse(req.body);
const before = await loadOrg(id);
const cols: Record<string, string> = { company: 'company', contactName: 'contact_name', street: 'street', zip: 'zip', city: 'city', country: 'country', vatId: 'vat_id', billingEmail: 'billing_email', phone: 'phone' };
const patch: Record<string, string | null> = {};
for (const [k, v] of Object.entries(b.billing ?? {})) if (v !== undefined) patch[k] = k === 'vatId' ? (normVat(v as string | null) || null) : ((v as string | null) || null);
// Ergebnis nach der Änderung gegen die Regeln der (neuen) Kundenart prüfen
const type = b.customerType ?? before.customerType;
const name = b.name ?? before.name;
const after = { company: 'company' in patch ? patch.company : before.billing.company, vatId: 'vatId' in patch ? patch.vatId : before.billing.vatId };
if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY');
if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); }
await tx(async (c) => {
if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c);
const keys = Object.keys(patch);
if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c);
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.update', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before, after: b });
return loadOrg(id);
});
// ---- Übernahme bestehender Kunden aus einem Anbieter (z. B. KeyHelp) --------------
const capsOf = (inst: any): string[] => (inst.capabilities_json ? (typeof inst.capabilities_json === 'string' ? JSON.parse(inst.capabilities_json) : inst.capabilities_json) : []);
const legacyRef = (inst: any, ref: string) => `${inst.connector_key}:${inst.id}:${ref}`;
async function liveCustomers(instId: string, corr: string): Promise<{ inst: any; list: ImportableCustomer[] }> {
const inst = await one('SELECT * FROM connector_instances WHERE id = ?', [instId]); if (!inst) throw notFound();
if (!capsOf(inst).includes('customers.list')) throw badRequest('Diese Verbindung unterstützt keine Kundenübernahme', 'NO_CUSTOMERS');
try { const { connector, ctx } = await loadInstance(instId, corr); return { inst, list: await connector.listCustomers!(ctx) }; }
catch (e) { if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Der Anbieter konnte nicht gelesen werden: ${e.userMessage}`); throw e; }
}
/** Kunden des Anbieters mit Übernahmestatus, Produkt-Vorschlägen und möglichen vorhandenen Kunden (gleiche E-Mail/Firma). */
app.get('/admin/connectors/:id/customers', async (req) => {
requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const { inst, list } = await liveCustomers(id, req.correlationId);
const orgs = await query('SELECT o.id, o.customer_number, o.name, o.legacy_ref, b.billing_email FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id');
const byLegacy = new Map(orgs.filter((o) => o.legacy_ref).map((o) => [o.legacy_ref as string, o]));
const res = await query('SELECT external_ref, org_id FROM resources WHERE instance_id = ?', [id]); const resOrg = new Map(res.map((r) => [r.external_ref as string, r.org_id as string | null]));
const prods = await query('SELECT p.id, p.sku, p.external_ref, p.status, v.name FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.connector_instance_id = ?', [id]);
return list.map((c) => {
const done = byLegacy.get(legacyRef(inst, c.externalRef));
const matches = orgs.filter((o) => !o.legacy_ref && ((c.email && o.billing_email && String(o.billing_email).toLowerCase() === c.email.toLowerCase()) || (c.company && String(o.name).toLowerCase() === c.company.toLowerCase())));
return { ...c, imported: done ? { orgId: done.id, customerNumber: done.customer_number } : null, resourceKnown: resOrg.has(c.externalRef), resourceOrgId: resOrg.get(c.externalRef) ?? null,
suggestedProducts: prods.filter((p) => c.planRef && p.external_ref === c.planRef).map((p) => ({ id: p.id, sku: p.sku, name: p.name, status: p.status })),
possibleOrgs: matches.map((o) => ({ id: o.id, customerNumber: o.customer_number, name: o.name })) };
});
});
const importItem = z.object({
externalRef: z.string().min(1).max(100), type: z.enum(['private', 'business']), name: z.string().trim().min(1).max(200).optional(), ownerName: z.string().trim().min(1).max(150).optional(), ownerEmail: email.optional(),
linkToOrgId: z.string().uuid().optional(), contract: z.object({ productId: z.string().uuid(), startedAt: z.string().max(40).optional() }).optional(),
});
/**
* Übernimmt ausgewählte Kunden: legt Kunde (Organisation, Rechnungsanschrift, Inhaber) an oder verknüpft mit einem vorhandenen Kunden,
* ordnet das Hosting-Konto zu und legt optional einen laufenden Bestandsvertrag an. Es werden KEINE E-Mails versendet (Einladung später gezielt).
*/
app.post('/admin/connectors/:id/customers/import', async (req) => {
const a = requirePermission(req, 'customers.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ items: z.array(importItem).min(1).max(200) }).parse(req.body);
const { inst, list } = await liveCustomers(id, req.correlationId);
const byRef = new Map(list.map((c) => [c.externalRef, c]));
// Fehlende lokale Ressourcen-Spiegel vorab abgleichen (einmalig)
const known = async () => new Map((await query('SELECT id, external_ref, org_id, state, valid_from FROM resources WHERE instance_id = ?', [id])).map((r) => [r.external_ref as string, r]));
let resMap = await known();
if (b.items.some((i) => !resMap.has(i.externalRef))) { await syncInstance(id, req.correlationId).catch(() => undefined); resMap = await known(); }
const results: { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }[] = [];
const seq = async (c: Parameters<typeof one>[2], name: 'order' | 'contract', prefix: string) => { await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c as never); return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`; };
for (const it of b.items) {
const c = byRef.get(it.externalRef); const res = resMap.get(it.externalRef); const skip = (reason: string) => results.push({ externalRef: it.externalRef, status: 'skipped', reason });
if (!c) { skip('Beim Anbieter nicht gefunden'); continue; }
if (!res) { skip('Das Konto ist noch nicht abgeglichen (Verbindung prüfen)'); continue; }
if (await one('SELECT 1 AS x FROM organizations WHERE legacy_ref = ?', [legacyRef(inst, c.externalRef)])) { skip('Bereits übernommen'); continue; }
if (res.org_id && !it.linkToOrgId) { skip('Das Konto ist bereits einem Kunden zugeordnet'); continue; }
const name = it.name ?? (it.type === 'business' ? (c.company ?? c.displayName) : ([c.firstName, c.lastName].filter(Boolean).join(' ') || c.displayName));
try {
let product: any = null;
if (it.contract) {
product = await one('SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [it.contract.productId]);
if (!product || product.connector_instance_id !== id) throw badRequest('Das Produkt gehört nicht zu dieser Verbindung', 'BAD_PRODUCT');
if (await one('SELECT 1 AS x FROM contracts WHERE resource_id = ?', [res.id])) throw badRequest('Für dieses Konto gibt es bereits einen Vertrag', 'CONTRACT_EXISTS');
}
const ownerEmail = (it.ownerEmail ?? c.email ?? '').toLowerCase();
let orgId = it.linkToOrgId ?? ''; let customerNumber = ''; let orgType: 'private' | 'business' = it.type; let contractNumber: string | undefined;
await tx(async (cn) => {
if (it.linkToOrgId) {
const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG');
orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type;
} else {
if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL');
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS');
if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ }
orgId = randomUUID(); customerNumber = await nextCustomerNumber(cn);
await run('INSERT INTO organizations (id, customer_number, name, customer_type, legacy_ref) VALUES (?,?,?,?,?)', [orgId, customerNumber, name, it.type, legacyRef(inst, c.externalRef)], cn);
const country = c.address.country && /^[A-Za-z]{2}$/.test(c.address.country) ? c.address.country.toUpperCase() : 'DE';
await run('INSERT INTO billing_profiles (org_id, company, contact_name, street, zip, city, country, billing_email, phone) VALUES (?,?,?,?,?,?,?,?,?)',
[orgId, it.type === 'business' ? (c.company ?? name) : null, it.type === 'private' ? name : ([c.firstName, c.lastName].filter(Boolean).join(' ') || null), c.address.street, c.address.zip, c.address.city, country, ownerEmail, c.phone], cn);
const u = await createInvitedUser(cn, { email: ownerEmail, name: it.ownerName ?? ([c.firstName, c.lastName].filter(Boolean).join(' ') || name), kind: 'customer' }); // Einladung wird NICHT versendet
await run("INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,'owner')", [orgId, u.userId], cn);
}
if (it.linkToOrgId) await run('UPDATE organizations SET legacy_ref = COALESCE(legacy_ref, ?) WHERE id = ?', [legacyRef(inst, c.externalRef), orgId], cn);
await run('UPDATE resources SET org_id = ?, customer_actions = COALESCE(?, customer_actions) WHERE id = ?', [orgId, product ? product.customer_actions : null, res.id], cn);
if (product) {
const now = new Date(); const started = it.contract!.startedAt && !Number.isNaN(Date.parse(it.contract!.startedAt)) ? new Date(it.contract!.startedAt) : (c.createdAt ? new Date(c.createdAt) : now);
let terms = { termMonths: product.term_months as number, renewal: product.renewal as 'auto' | 'none', renewalTermMonths: product.renewal_term_months as number, noticeDays: product.notice_days as number };
if (orgType === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) };
const price = calculatePrice({ basis: product.price_basis, setupCents: product.setup_cents, recurringCents: product.recurring_cents, taxBp: product.tax_bp, interval: product.billing_interval, quantity: 1, discountBp: 0 });
const snapshot = { productId: product.id, sku: product.sku, productVersionId: product.vid, version: product.version, name: product.vname, category: product.category, customerType: orgType, ...price, terms, imported: true };
// Laufzeitende: erste Periode ab Beginn, bei automatischer Verlängerung bis in die Zukunft fortgeschrieben
const period = terms.termMonths > 0 ? terms.termMonths : terms.renewal === 'auto' ? terms.renewalTermMonths : 0; let termEnd: Date | null = null;
if (period > 0) { termEnd = addMonths(started, period); const step = terms.renewal === 'auto' && terms.renewalTermMonths > 0 ? terms.renewalTermMonths : 0; let g = 0; while (termEnd <= now && step > 0 && g++ < 1200) termEnd = addMonths(termEnd, step); if (termEnd <= now) termEnd = null; }
const orderId = randomUUID(); const itemId = randomUUID(); const oNum = await seq(cn, 'order', 'B'); contractNumber = await seq(cn, 'contract', 'V');
await run("INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,'completed',?,'staff',0,?,UTC_TIMESTAMP(3),?)", [orderId, oNum, orgId, a.user.id, a.user.id, `Übernahme aus ${inst.name} (Bestand)`], cn);
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,1,0,?)', [itemId, orderId, product.vid, JSON.stringify(snapshot)], cn);
await run('INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, started_at, term_end, renewal, renewal_term_months, notice_days, resource_id, price_snapshot_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
[randomUUID(), contractNumber, orgId, itemId, product.vid, res.state === 'suspended' ? 'suspended' : 'active', started, termEnd, terms.renewal, terms.renewalTermMonths, terms.noticeDays, res.id, JSON.stringify(snapshot)], cn);
}
});
await audit({ actorType: 'user', actorId: a.user.id, orgId, action: 'customer.import', resourceType: 'organization', resourceId: orgId, connector: inst.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { source: legacyRef(inst, c.externalRef), customerNumber, linked: !!it.linkToOrgId, contractNumber } });
results.push({ externalRef: it.externalRef, status: it.linkToOrgId ? 'linked' : 'created', orgId, customerNumber, contractNumber });
} catch (e) { skip(e instanceof AppError ? e.message : 'Fehler bei der Übernahme'); }
}
return { results };
});
// ---- Kundenseite: eigene Organisation ---------------------------------
app.get('/orgs/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound(); // keine Existenz verraten
return loadOrg(id);
});
app.get('/orgs/:id/members', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.members.read', 'customers.read')) throw notFound();
return (await loadOrg(id)).members;
});
app.post('/orgs/:id/invitations', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
if (!canInOrg(a.principal, id, 'org.read', 'customers.read')) throw notFound();
if (!canInOrg(a.principal, id, 'org.members.invite', 'customers.write')) throw forbidden();
const b = z.object({ email, name: z.string().trim().min(1).max(150), role: z.enum(['admin', 'member']) }).parse(req.body);
if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('Diese E-Mail ist bereits registriert', 'EMAIL_EXISTS');
const inv = await tx(async (c) => {
const i = await createInvitedUser(c, { email: b.email, name: b.name, kind: 'customer' });
await run('INSERT INTO memberships (org_id, user_id, role) VALUES (?,?,?)', [id, i.userId, b.role], c);
return i;
});
const mail = await mailInvite(b.email, b.name, inv.token);
await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'org.member.invite', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, role: b.role } });
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
});
},
};
async function loadOrg(id: string) {
const o = await one('SELECT o.*, b.company, b.contact_name, b.street, b.zip, b.city, b.country, b.vat_id, b.billing_email, b.phone FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [id]);
if (!o) throw notFound('Kunde nicht gefunden');
const members = await query('SELECT u.id, u.email, u.name, u.status, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.created_at', [id]);
return {
id: o.id as string, customerNumber: o.customer_number as string, name: o.name as string, customerType: o.customer_type as 'private' | 'business', status: o.status as string, createdAt: o.created_at as Date,
billing: { company: o.company, contactName: o.contact_name, street: o.street, zip: o.zip, city: o.city, country: o.country, vatId: o.vat_id, billingEmail: o.billing_email, phone: o.phone },
members: members.map((m) => ({ id: m.id as string, email: m.email as string, name: m.name as string, status: m.status as string, role: m.role as string })),
};
}

View file

@ -0,0 +1,132 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { requireAuth, requirePermission, clientIp } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { rl } from '../../core/config.js';
import type { KcModule } from '../../core/module.js';
import { randomUUID } from 'node:crypto';
import { loadInstance } from '@kc/connectors';
import { breakdown, checkAvailability, normalizeDomain, parsePriceList, sellPrice, splitDomain, type CostBasis, type Margin } from './logic.js';
const taxBp = async () => Number((await one("SELECT rate_bp FROM tax_rates WHERE name LIKE 'Regelsteuersatz%' LIMIT 1"))?.rate_bp ?? 1900);
/** Preis-Schnappschuss für eine Domain: Einkauf brutto/netto und errechneter Verkauf brutto/netto. Null-Preise, wenn Endung fehlt oder kein Aufschlag festgelegt ist. */
async function snapshot(tld: string) {
const s = await settings(); const r = await one('SELECT * FROM domain_tlds WHERE tld = ?', [tld]); const tax = await taxBp();
if (!r) return { termMonths: null, costNet: null, costGross: null, setup: 0, net: null, gross: null, tax };
const o = offer(r, s.tier, s.margin, s.rounding, s.basis, tax);
return { termMonths: o.termMonths, costNet: o.costNetCents, costGross: o.costGrossCents, setup: o.setupGrossCents, net: o.priceNetCents, gross: o.priceGrossCents, tax };
}
const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months,
costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents,
procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at });
const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id';
const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info'];
const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen');
async function settings() { const s = await one('SELECT tier, margin_type, margin_value, rounding, cost_basis FROM domain_settings WHERE id = 1'); return { tier: Number(s?.tier ?? 1), rounding: s ? !!s.rounding : true, basis: (s?.cost_basis ?? 'gross') as CostBasis, margin: { type: s?.margin_type ?? null, value: s?.margin_value ?? null } as Margin }; }
const offer = (r: any, tier: number, g: Margin, round: boolean, basis: CostBasis, tax: number) => {
const list = Number(r[`cost${tier}_cents`]); const own: Margin = { type: r.margin_type, value: r.margin_value }; const b = breakdown(list, own, g, round, basis, tax);
const setupGross = basis === 'gross' ? r.setup_cents : r.setup_cents + Math.round((r.setup_cents * tax) / 10000);
return { tld: r.tld, termMonths: r.term_months, ...b, setupGrossCents: setupGross as number, setupNetCents: Math.round((setupGross * 10000) / (10000 + tax)), taxBp: tax, margin: own, active: !!r.active };
};
export const domainsModule: KcModule = {
name: 'domains',
permissions: { staff: { support: ['domains.read'], accounting: ['domains.read'], admin: ['domains.read', 'domains.write'], superadmin: ['domains.read', 'domains.write'] } },
register(app: FastifyInstance) {
// Domain prüfen (für alle angemeldeten Benutzer). Ohne Endung werden gängige Endungen geprüft. Einkaufspreise erscheinen hier nie.
app.get('/domains/check', { config: rl(30, '1 minute') }, async (req) => {
requireAuth(req);
const { name } = z.object({ name: z.string().min(1).max(300) }).parse(req.query);
const s = await settings(); const tax = await taxBp(); const rows = await query('SELECT * FROM domain_tlds WHERE active = 1'); const by = new Map(rows.map((r) => [r.tld as string, r]));
const norm = normalizeDomain(name.includes('.') ? name : `${name}.de`); if (!norm) throw badRequest('Das ist kein gültiger Domainname. Erlaubt sind Buchstaben, Ziffern und Bindestriche.');
const names = name.includes('.') ? [norm] : SUGGEST.filter((t) => by.has(t)).map((t) => `${norm.split('.')[0]}.${t}`);
const results = await Promise.all(names.map(async (n) => {
const sp = splitDomain(n, new Set(by.keys()))!; const o = by.get(sp.tld); const price = o ? offer(o, s.tier, s.margin, s.rounding, s.basis, tax) : null; const a = await checkAvailability(n);
return { domain: n, tld: sp.tld, ...a, offer: price && price.priceGrossCents !== null ? { termMonths: price.termMonths, priceGrossCents: price.priceGrossCents, priceNetCents: price.priceNetCents!, taxBp: tax, setupGrossCents: price.setupGrossCents } : null, offered: !!o };
}));
return { results };
});
app.get('/admin/domain-tlds', async (req) => {
requirePermission(req, 'domains.read'); const s = await settings(); const tax = await taxBp();
return { settings: { tier: s.tier, margin: s.margin, rounding: s.rounding, basis: s.basis, taxBp: tax }, tlds: (await query('SELECT * FROM domain_tlds ORDER BY tld')).map((r) => offer(r, s.tier, s.margin, s.rounding, s.basis, tax)) };
});
// ---- Domain-Aufstellung (Einkauf beim Registrar KCS vs. errechneter Verkauf) ----
app.get('/admin/domain-records', async (req) => {
requirePermission(req, 'domains.read'); const q = z.object({ status: z.enum(['open', 'ordered', 'external']).optional(), q: z.string().max(100).optional(), orgId: z.string().uuid().optional() }).parse(req.query);
const rows = await query(`${REC_SELECT} WHERE (? IS NULL OR d.org_id = ?) AND (? IS NULL OR d.procurement = ?) AND (? IS NULL OR d.domain LIKE ?) ORDER BY d.created_at DESC LIMIT 2000`, [q.orgId ?? null, q.orgId ?? null, q.status ?? null, q.status ?? null, q.q ?? null, `%${q.q ?? ''}%`]);
return rows.map(recView);
});
app.post('/admin/domain-records', async (req) => {
const a = requirePermission(req, 'domains.write'); const b = z.object({ domain: z.string().max(300), orgId: z.string().uuid().nullable().optional(), procurement: z.enum(['open', 'ordered', 'external']).default('open'), note: z.string().max(300).optional() }).parse(req.body);
const name = normalizeDomain(b.domain); if (!name || !name.includes('.')) throw badRequest('Das ist kein gültiger Domainname.');
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const sp = splitDomain(name, known)!;
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) throw badRequest('Diese Domain ist bereits erfasst.');
const p = await snapshot(sp.tld); const id = randomUUID();
await run('INSERT INTO domain_records (id, domain, tld, org_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, procurement, note, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [id, name, sp.tld, b.orgId ?? null, 'manual', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, b.procurement, b.note ?? null]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.create', resourceType: 'domain_record', resourceId: id, after: { domain: name }, ip: clientIp(req) });
return { id };
});
// Domains eines Hosting-Kontos aus dem Panel übernehmen (Subdomains und System-Domain werden ausgelassen)
app.post('/admin/domain-records/from-resource/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await one('SELECT r.id, r.org_id, r.external_ref, r.instance_id, i.capabilities_json FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]); if (!r) throw notFound();
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); if (!connector.children) throw badRequest('Diese Verbindung liefert keine Domains.');
const list = await connector.children.list(ctx, r.external_ref, 'domain' as never) as { name: string; details?: { subdomain?: boolean; system?: boolean } }[];
const known = new Set((await query('SELECT tld FROM domain_tlds')).map((x) => x.tld as string)); let added = 0, skipped = 0, unpriced = 0;
for (const d of list) {
const name = normalizeDomain(d.name); if (!name || d.details?.subdomain || d.details?.system) { skipped++; continue; }
if (await one('SELECT 1 AS x FROM domain_records WHERE domain = ?', [name])) { skipped++; continue; }
const sp = splitDomain(name, known); if (!sp) { skipped++; continue; } const p = await snapshot(sp.tld); if (p.net === null) unpriced++;
await run('INSERT INTO domain_records (id, domain, tld, org_id, resource_id, source, term_months, cost_net_cents, cost_gross_cents, setup_cost_cents, sell_net_cents, tax_bp, sell_gross_cents, priced_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP(3))', [randomUUID(), name, sp.tld, r.org_id, id, 'keyhelp', p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross]); added++;
}
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.import', resourceType: 'resource', resourceId: id, after: { added, skipped }, ip: clientIp(req) });
return { added, skipped, unpriced };
});
app.patch('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body);
const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound();
if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]);
if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]);
if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]);
if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]);
if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); }
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.update', resourceType: 'domain_record', resourceId: id, after: b, ip: clientIp(req) });
return { ok: true };
});
app.delete('/admin/domain-records/:id', async (req) => {
const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
await run('DELETE FROM domain_records WHERE id = ?', [id]); await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.record.delete', resourceType: 'domain_record', resourceId: id, ip: clientIp(req) }); return { ok: true };
});
app.put('/admin/domain-settings', async (req) => {
const a = requirePermission(req, 'domains.write');
const b = z.object({ tier: z.number().int().min(1).max(4), margin: marginIn, rounding: z.boolean(), basis: z.enum(['gross', 'net']) }).parse(req.body);
await run('UPDATE domain_settings SET tier = ?, margin_type = ?, margin_value = ?, rounding = ?, cost_basis = ? WHERE id = 1', [b.tier, b.margin.type, b.margin.value, b.rounding ? 1 : 0, b.basis]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.settings', resourceType: 'domain_settings', resourceId: '1', after: b, ip: clientIp(req) });
return { ok: true };
});
app.patch('/admin/domain-tlds/:tld', async (req) => {
const a = requirePermission(req, 'domains.write'); const { tld } = z.object({ tld: z.string().max(63) }).parse(req.params);
const b = z.object({ margin: marginIn.optional(), active: z.boolean().optional() }).parse(req.body);
if (!(await one('SELECT 1 AS x FROM domain_tlds WHERE tld = ?', [tld]))) throw notFound();
if (b.margin) await run('UPDATE domain_tlds SET margin_type = ?, margin_value = ? WHERE tld = ?', [b.margin.type, b.margin.value, tld]);
if (b.active !== undefined) await run('UPDATE domain_tlds SET active = ? WHERE tld = ?', [b.active ? 1 : 0, tld]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.tld.update', resourceType: 'domain_tld', resourceId: tld, after: b, ip: clientIp(req) });
return { ok: true };
});
// Preisliste einlesen (Einkaufspreise); Aufschläge und Aktiv-Status bestehender Endungen bleiben erhalten.
app.post('/admin/domain-tlds/import', async (req) => {
const a = requirePermission(req, 'domains.write'); const { text, dryRun } = z.object({ text: z.string().min(1).max(500_000), dryRun: z.boolean().default(false) }).parse(req.body);
const { rows, skipped } = parsePriceList(text); if (rows.length === 0) throw badRequest('Es wurden keine Preiszeilen erkannt. Erwartet: Endung, Laufzeit, vier Staffelpreise, optional Setup.');
const existing = new Set((await query('SELECT tld FROM domain_tlds')).map((r) => r.tld as string)); const created = rows.filter((r) => !existing.has(r.tld)).length;
if (!dryRun) {
await tx(async (c) => { for (const r of rows) await run('INSERT INTO domain_tlds (tld, term_months, cost1_cents, cost2_cents, cost3_cents, cost4_cents, setup_cents) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE term_months=VALUES(term_months), cost1_cents=VALUES(cost1_cents), cost2_cents=VALUES(cost2_cents), cost3_cents=VALUES(cost3_cents), cost4_cents=VALUES(cost4_cents), setup_cents=VALUES(setup_cents)', [r.tld, r.termMonths, ...r.costs, r.setupCents], c); });
await audit({ actorType: 'user', actorId: a.user.id, action: 'domains.import', resourceType: 'domain_tld', resourceId: 'bulk', after: { rows: rows.length, created }, ip: clientIp(req) });
}
return { rows: rows.length, created, updated: rows.length - created, skipped, dryRun };
});
},
};

View file

@ -0,0 +1,93 @@
import { domainToASCII } from 'node:url';
import { promises as dns } from 'node:dns';
export type MarginType = 'percent' | 'fixed';
export interface Margin { type: MarginType | null; value: number | null }
/** Kaufmännische Rundung nach oben: Cent-Anteil 00–50 → ,50; 51–99 → ,99 (z. B. 12,00 → 12,50; 12,51 → 12,99). */
export function roundPrice(cents: number): number { const euro = Math.floor(cents / 100), c = cents % 100; return euro * 100 + (c <= 50 ? 50 : 99); }
/** Verkaufspreis (netto, Cent) = Einkauf + Gewinnaufschlag; null, wenn kein Aufschlag festgelegt ist. Prozent in Basispunkten. */
export function sellPrice(costCents: number, own: Margin, global: Margin, round = false): number | null {
const m = own.type && own.value !== null ? own : global; if (!m.type || m.value === null) return null;
const p = costCents + (m.type === 'percent' ? Math.round((costCents * m.value) / 10000) : m.value); return round ? roundPrice(p) : p;
}
export type CostBasis = 'gross' | 'net';
export interface Breakdown { costGrossCents: number; costNetCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null }
/** Preise aus dem Listenpreis: Ist die Liste brutto (inkl. USt), wird Netto herausgerechnet, nie erneut USt aufgeschlagen. Rundung und Aufschlag wirken auf die Listenbasis. */
export function breakdown(listCost: number, own: Margin, global: Margin, round: boolean, basis: CostBasis, taxBp: number): Breakdown {
const toNet = (g: number) => Math.round((g * 10000) / (10000 + taxBp)); const vat = (n: number) => Math.round((n * taxBp) / 10000);
const p = sellPrice(listCost, own, global, round);
const costGross = basis === 'gross' ? listCost : listCost + vat(listCost); const costNet = basis === 'gross' ? toNet(listCost) : listCost;
if (p === null) return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: null, priceNetCents: null, profitNetCents: null };
const g = basis === 'gross' ? p : p + vat(p); const n = basis === 'gross' ? toNet(p) : p;
return { costGrossCents: costGross, costNetCents: costNet, priceGrossCents: g, priceNetCents: n, profitNetCents: n - costNet };
}
export interface ParsedRow { tld: string; termMonths: number; costs: [number, number, number, number]; setupCents: number }
const num = (s: string): number | null => {
const t = s.replace(/[€\s]/g, ''); if (!t) return null;
const n = t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t; // deutsches Format 1.380,00 oder 26.60
return /^\d+(\.\d+)?$/.test(n) ? Math.round(Number(n) * 100) : null;
};
/** Liest eine Preisliste (Tabulator/Leerzeichen getrennt): Endung, Laufzeit, 4 Staffelpreise, optional Setup. Kopf-/Fußzeilen werden übersprungen. */
export function parsePriceList(text: string): { rows: ParsedRow[]; skipped: string[] } {
const rows: ParsedRow[] = []; const skipped: string[] = []; const seen = new Set<string>();
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim(); if (!line || line.startsWith('#')) continue;
const c = line.split(/[\t ]+/).filter((x) => x !== '€');
const tld = (c[0] ?? '').toLowerCase().replace(/^\./, '');
if (!/^[a-z0-9]([a-z0-9.-]{0,60}[a-z0-9])?$/.test(tld) || !/^\d{1,3}$/.test(c[1] ?? '')) { if (!/^Domaintyp/i.test(line)) skipped.push(line.slice(0, 80)); continue; }
const p = [2, 3, 4, 5].map((i) => num(c[i] ?? '')); const setup = c[6] !== undefined ? num(c[6]) : 0;
if (p.some((x) => x === null) || setup === null) { skipped.push(line.slice(0, 80)); continue; }
if (seen.has(tld)) { skipped.push(`doppelt: ${tld}`); continue; } seen.add(tld);
rows.push({ tld, termMonths: Number(c[1]), costs: p as [number, number, number, number], setupCents: setup });
}
return { rows, skipped };
}
/** Normalisiert Eingaben wie „https://www.Beispiel.de/x“ zu „beispiel.de“ (Punycode). */
export function normalizeDomain(input: string): string | null {
let s = input.trim().toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/[/?#].*$/, '').replace(/^www\./, '').replace(/\.$/, '');
if (!s || s.length > 253) return null; s = domainToASCII(s); if (!s) return null;
return s.split('.').every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l)) ? s : null;
}
/** Trennt Name und Endung; bekannte mehrteilige Endungen (co.uk) werden bevorzugt. */
export function splitDomain(name: string, known: Set<string>): { label: string; tld: string } | null {
const parts = name.split('.'); if (parts.length < 2) return null;
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld) && parts.slice(0, i).length === 1) return { label: parts[0]!, tld }; }
for (let i = 1; i < parts.length; i++) { const tld = parts.slice(i).join('.'); if (known.has(tld)) return { label: parts.slice(0, i).join('.'), tld }; }
return { label: parts.slice(0, -1).join('.'), tld: parts[parts.length - 1]! };
}
export type Availability = { status: 'available' | 'registered' | 'unknown'; method: 'rdap' | 'dns'; note?: string };
/** Ergänzung für Endungen, die nicht in der IANA-Liste stehen, aber eine RDAP-Auskunft anbieten. */
const EXTRA_RDAP: Record<string, string> = { de: 'https://rdap.denic.de/' };
let boot: { at: number; map: Map<string, string> } | null = null;
async function rdapBase(tld: string): Promise<string | null> {
if (!boot || Date.now() - boot.at > 24 * 3600_000) {
const r = await fetch('https://data.iana.org/rdap/dns.json', { signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error(`IANA ${r.status}`);
const j = (await r.json()) as { services: [string[], string[]][] }; const map = new Map<string, string>();
for (const [tlds, urls] of j.services) { const u = urls.find((x) => x.startsWith('https://')); if (u) for (const t of tlds) map.set(t.toLowerCase(), u.endsWith('/') ? u : `${u}/`); }
boot = { at: Date.now(), map };
}
const last = tld.split('.').pop()!; return boot.map.get(last) ?? EXTRA_RDAP[last] ?? null;
}
async function viaDns(name: string): Promise<Availability> {
try { await dns.resolveNs(name); return { status: 'registered', method: 'dns' }; } catch (e) {
const c = (e as { code?: string }).code;
if (c === 'ENOTFOUND') return { status: 'available', method: 'dns', note: 'Ermittelt über DNS, ohne Gewähr. Vor der Bestellung wird beim Registrar erneut geprüft.' };
if (c === 'ENODATA') { try { await dns.resolveSoa(name); return { status: 'registered', method: 'dns' }; } catch { return { status: 'unknown', method: 'dns' }; } }
return { status: 'unknown', method: 'dns' };
}
}
/** Prüft, ob eine Domain vergeben ist: bevorzugt per RDAP (Registry-Auskunft), sonst per DNS. */
export async function checkAvailability(name: string): Promise<Availability> {
try {
const base = await rdapBase(name.split('.').slice(1).join('.'));
if (base) {
const r = await fetch(`${base}domain/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(7000), headers: { accept: 'application/rdap+json, application/json' }, redirect: 'follow' });
if (r.status === 200) return { status: 'registered', method: 'rdap' };
if (r.status === 404) return { status: 'available', method: 'rdap' };
}
} catch { /* Fallback auf DNS */ }
return viaDns(name);
}

View file

@ -0,0 +1,277 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { hash, verify } from '@node-rs/argon2';
import * as OTPAuth from 'otpauth';
import { randomBytes } from 'node:crypto';
import { rl } from '../../core/config.js';
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 { decrypt, encrypt, sha256 } from '../../core/crypto.js';
import { can, staffPermissions } from '../../core/policy.js';
import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js';
import { sendMail } from '../../core/mail.js';
import type { KcModule } from '../../core/module.js';
const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 };
const MAX_FAILS = 5;
const LOCK_MINUTES = 15;
const password = z.string().min(12, 'Mindestens 12 Zeichen').max(200);
const email = z.string().email().max(254).transform((s) => s.toLowerCase());
// Scheinhash gegen Timing-Unterschiede bei unbekannten Benutzern
const DUMMY = await hash('dummy-password-for-timing', ARGON);
function totp(secret: string, label: string): OTPAuth.TOTP {
return new OTPAuth.TOTP({ issuer: 'Kundencenter', label, algorithm: 'SHA1', digits: 6, period: 30, secret: OTPAuth.Secret.fromBase32(secret) });
}
/** Prüft TOTP mit ±1 Schritt und verhindert Wiederverwendung desselben Schritts. */
async function checkTotp(userId: string, code: string): Promise<boolean> {
const row = await one('SELECT secret_enc, last_step FROM mfa_totp WHERE user_id = ?', [userId]);
if (!row) return false;
const t = totp(decrypt(row.secret_enc), 'x');
const delta = t.validate({ token: code.replace(/\s/g, ''), window: 1 });
if (delta === null) return false;
const step = Math.floor(Date.now() / 30000) + delta;
if (row.last_step !== null && Number(row.last_step) >= step) return false;
await run('UPDATE mfa_totp SET last_step = ? WHERE user_id = ?', [step, userId]);
return true;
}
async function useRecoveryCode(userId: string, code: string): Promise<boolean> {
const r = await run('UPDATE recovery_codes SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND code_hash = ? AND used_at IS NULL', [userId, sha256(code.trim().toLowerCase())]);
return r.affectedRows === 1;
}
const publicUser = (u: { id: string; email: string; name: string; kind: string; staff_role: string | null; status: string }) => ({ id: u.id, email: u.email, name: u.name, kind: u.kind, staffRole: u.staff_role, status: u.status });
export const identityModule: KcModule = {
name: 'identity',
register(app: FastifyInstance) {
// ---- Login -------------------------------------------------------------
app.post('/auth/login', { config: rl(10, '1 minute') }, async (req, reply) => {
const body = z.object({ email, password: z.string().max(200) }).parse(req.body);
const u = await one('SELECT * FROM users WHERE email = ?', [body.email]);
const locked = u?.locked_until && new Date(u.locked_until as Date) > new Date();
const ok = await verify(u?.password_hash ?? DUMMY, body.password).catch(() => false);
if (!u || !u.password_hash || !ok || u.status !== 'active' || locked) {
if (u && !locked && u.status === 'active') {
const fails = Number(u.failed_logins) + 1;
await run('UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?', [fails, fails >= MAX_FAILS ? new Date(Date.now() + LOCK_MINUTES * 60000) : null, u.id]);
}
await audit({ actorType: 'anonymous', action: 'auth.login', result: 'denied', errorClass: locked ? 'locked' : 'invalid_credentials', correlationId: req.correlationId, ip: clientIp(req), after: { email: body.email } });
throw unauthorized('E-Mail oder Passwort falsch', 'INVALID_CREDENTIALS');
}
await run('UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?', [u.id]);
if (req.auth) await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]); // keine Session-Fixation
const hasMfa = !!(await one('SELECT 1 AS x FROM mfa_totp WHERE user_id = ? AND confirmed_at IS NOT NULL', [u.id]));
const s = await createSession(reply, u.id, { pendingMfa: hasMfa, ip: clientIp(req), ua: req.headers['user-agent'] ?? '' });
await audit({ actorType: 'user', actorId: u.id, action: hasMfa ? 'auth.login.password_ok' : 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
return { status: hasMfa ? 'mfa_required' : 'ok', csrf: s.csrf };
});
app.post('/auth/mfa/verify', { config: rl(10, '1 minute') }, async (req) => {
const a = requireAuth(req, { allowPendingMfa: true });
if (!a.pendingMfa) return { status: 'ok' };
const { code } = z.object({ code: z.string().min(6).max(20) }).parse(req.body);
const good = /^\d{6}$/.test(code.replace(/\s/g, '')) ? await checkTotp(a.user.id, code) : await useRecoveryCode(a.user.id, code);
if (!good) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
throw unauthorized('Code ungültig', 'INVALID_MFA');
}
await run('UPDATE sessions SET pending_mfa = 0, mfa_verified = 1 WHERE id = ?', [a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.login', correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/logout', async (req, reply) => {
if (req.auth) {
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ?', [req.auth.sessionId]);
await audit({ actorType: 'user', actorId: req.auth.user.id, action: 'auth.logout', correlationId: req.correlationId, ip: clientIp(req) });
}
reply.clearCookie(COOKIE, { path: '/' });
return { status: 'ok' };
});
app.get('/auth/me', async (req) => {
const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true });
const orgs = a.principal.memberships.length
? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id])
: [];
return {
user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole),
pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled,
organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf,
};
});
// ---- MFA-Einrichtung ---------------------------------------------------
app.post('/auth/mfa/setup', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (a.mfaEnrolled) throw conflict('2FA ist bereits aktiv', 'MFA_ALREADY_ENABLED');
const secret = new OTPAuth.Secret({ size: 20 });
await run('REPLACE INTO mfa_totp (user_id, secret_enc) VALUES (?,?)', [a.user.id, encrypt(secret.base32)]);
return { secret: secret.base32, otpauthUrl: totp(secret.base32, a.user.email).toString() };
});
app.post('/auth/mfa/confirm', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
const { code } = z.object({ code: z.string().regex(/^\d{6}$/) }).parse(req.body);
const row = await one('SELECT confirmed_at FROM mfa_totp WHERE user_id = ?', [a.user.id]);
if (!row || row.confirmed_at) throw badRequest('Keine offene 2FA-Einrichtung', 'NO_PENDING_MFA');
if (!(await checkTotp(a.user.id, code))) throw badRequest('Code ungültig', 'INVALID_MFA');
const codes = Array.from({ length: 10 }, () => randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2'));
await tx(async (c) => {
await run('UPDATE mfa_totp SET confirmed_at = UTC_TIMESTAMP(3) WHERE user_id = ?', [a.user.id], c);
await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c);
for (const code2 of codes) await run('INSERT INTO recovery_codes (user_id, code_hash) VALUES (?,?)', [a.user.id, sha256(code2)], c);
});
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.enable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { recoveryCodes: codes }; // einmalige Anzeige
});
/** 2FA entfernen (z. B. neues Handy): Passwort + aktueller Code oder Wiederherstellungscode nötig. Danach kann neu eingerichtet werden. */
app.post('/auth/mfa/disable', { config: rl(5, '10 minutes') }, async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
if (!a.mfaEnrolled) throw badRequest('2FA ist nicht aktiv', 'MFA_NOT_ENABLED');
const b = z.object({ password: z.string().max(200), code: z.string().min(6).max(20) }).parse(req.body);
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
const pwOk = !!u?.password_hash && (await verify(u.password_hash, b.password).catch(() => false));
const codeOk = pwOk && (/^\d{6}$/.test(b.code.replace(/\s/g, '')) ? await checkTotp(a.user.id, b.code) : await useRecoveryCode(a.user.id, b.code));
if (!pwOk || !codeOk) {
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', result: 'denied', correlationId: req.correlationId, ip: clientIp(req) });
throw forbidden('Passwort oder Code falsch', 'INVALID_CREDENTIALS');
}
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [a.user.id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [a.user.id], c); });
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.mfa.disable', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Passwort ----------------------------------------------------------
app.post('/auth/password/change', async (req) => {
const a = requireAuth(req, { allowUnenrolledStaff: true });
const b = z.object({ current: z.string(), next: password, repeat: z.string() }).parse(req.body);
if (b.next !== b.repeat) throw badRequest('Die neuen Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const u = await one('SELECT password_hash FROM users WHERE id = ?', [a.user.id]);
if (!u?.password_hash || !(await verify(u.password_hash, b.current))) throw forbidden('Aktuelles Passwort falsch', 'INVALID_CREDENTIALS');
await run('UPDATE users SET password_hash = ? WHERE id = ?', [await hash(b.next, ARGON), a.user.id]);
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND id <> ? AND revoked_at IS NULL', [a.user.id, a.sessionId]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.password.change', resourceType: 'user', resourceId: a.user.id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/password/forgot', { config: rl(5, '10 minutes') }, async (req) => {
const { email: e } = z.object({ email }).parse(req.body);
const u = await one('SELECT id, name FROM users WHERE email = ? AND status = \'active\'', [e]);
if (u) {
const t = await createToken(undefined, u.id, 'password_reset');
await sendMail(e, 'password_reset', 'Passwort zurücksetzen', `Hallo ${u.name},\n\nüber diesen Link können Sie Ihr Passwort zurücksetzen (1 Stunde gültig):\n${resetLink(t)}\n\nWenn Sie das nicht angefordert haben, ignorieren Sie diese E-Mail.\n`);
await audit({ actorType: 'anonymous', action: 'auth.password.forgot', resourceType: 'user', resourceId: u.id, correlationId: req.correlationId, ip: clientIp(req) });
}
return { status: 'accepted' }; // immer gleiche Antwort
});
async function consumeToken(token: string, purpose: 'invite' | 'password_reset') {
const r = await one('SELECT id, user_id FROM user_tokens WHERE token_hash = ? AND purpose = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token), purpose]);
if (!r) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
const upd = await run('UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE id = ? AND used_at IS NULL', [r.id]);
if (upd.affectedRows !== 1) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN');
return r.user_id as string;
}
app.post('/auth/password/reset', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const uid = await consumeToken(b.token, 'password_reset');
await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(b.password, ARGON), uid]);
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [uid]);
await audit({ actorType: 'user', actorId: uid, action: 'auth.password.reset', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.post('/auth/invite/accept', { config: rl(10, '10 minutes') }, async (req) => {
const b = z.object({ token: z.string().min(20).max(100), password, repeat: z.string() }).parse(req.body);
if (b.password !== b.repeat) throw badRequest('Die Passwörter stimmen nicht überein', 'PASSWORD_MISMATCH');
const uid = await consumeToken(b.token, 'invite');
await run('UPDATE users SET password_hash = ?, status = \'active\', email_verified_at = UTC_TIMESTAMP(3) WHERE id = ? AND status = \'invited\'', [await hash(b.password, ARGON), uid]);
await audit({ actorType: 'user', actorId: uid, action: 'auth.invite.accept', resourceType: 'user', resourceId: uid, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Sitzungen ---------------------------------------------------------
app.get('/auth/sessions', async (req) => {
const a = requireAuth(req);
const rows = await query('SELECT id, ip, user_agent, created_at, last_seen_at FROM sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3) ORDER BY last_seen_at DESC', [a.user.id]);
return rows.map((r) => ({ id: r.id, ip: r.ip, userAgent: r.user_agent, createdAt: r.created_at, lastSeenAt: r.last_seen_at, current: r.id === a.sessionId }));
});
app.delete('/auth/sessions/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE id = ? AND user_id = ? AND revoked_at IS NULL', [id, a.user.id]);
if (!r.affectedRows) throw notFound();
await audit({ actorType: 'user', actorId: a.user.id, action: 'auth.session.revoke', resourceType: 'session', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
// ---- Benutzerverwaltung (Staff) ---------------------------------------
app.get('/admin/users', async (req) => {
requirePermission(req, 'users.read');
const q = z.object({ q: z.string().max(100).optional(), kind: z.enum(['customer', 'staff']).optional() }).parse(req.query);
const rows = await query(
`SELECT id, email, name, kind, staff_role, status, created_at,
(SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = users.id AND m.confirmed_at IS NOT NULL) AS mfa
FROM users WHERE (? IS NULL OR email LIKE CONCAT('%', ?, '%') OR name LIKE CONCAT('%', ?, '%')) AND (? IS NULL OR kind = ?)
ORDER BY created_at DESC LIMIT 200`,
[q.q ?? null, q.q ?? null, q.q ?? null, q.kind ?? null, q.kind ?? null],
);
return rows.map((r) => ({ ...publicUser(r as never), mfa: Number(r.mfa) > 0, createdAt: r.created_at }));
});
app.post('/admin/users', async (req) => {
const a = requirePermission(req, 'users.write');
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 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 } });
return { id: inv.userId, mail, inviteLink: mail === 'sent' ? undefined : inviteLink(inv.token) };
});
/** Support-/Admin-Reset der 2FA (Kunde hat Handy und Wiederherstellungscodes verloren). Beendet alle Sitzungen; bei Mitarbeitern muss neu eingerichtet werden. */
app.post('/admin/users/:id/mfa-reset', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const u = await one('SELECT id, kind, staff_role FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (id === a.user.id) throw forbidden('Eigene 2FA bitte unter „Mein Konto“ zurücksetzen', 'SELF_CHANGE');
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen die 2FA von Mitarbeitern zurücksetzen', 'PRIVILEGED_ONLY');
await tx(async (c) => { await run('DELETE FROM recovery_codes WHERE user_id = ?', [id], c); await run('DELETE FROM mfa_totp WHERE user_id = ?', [id], c); });
await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.mfa.reset', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
/** Einladung (erneut) senden: für eingeladene Benutzer, z. B. nach einer Kundenübernahme (dort wird bewusst nichts versendet). */
app.post('/admin/users/:id/reinvite', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const u = await one('SELECT id, email, name, kind, status, staff_role FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (u.status !== 'invited') throw badRequest('Nur eingeladene Benutzer haben eine offene Einladung', 'NOT_INVITED');
if (u.kind === 'staff' && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
await run("UPDATE user_tokens SET used_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND purpose = 'invite' AND used_at IS NULL", [id]);
const token = await createToken(undefined, id, 'invite');
const mail = await mailInvite(u.email, u.name, token);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.reinvite', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { mail } });
return { mail, inviteLink: mail === 'sent' ? undefined : inviteLink(token) };
});
app.patch('/admin/users/:id', async (req) => {
const a = requirePermission(req, 'users.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ status: z.enum(['active', 'disabled']).optional(), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']).optional() }).parse(req.body);
const u = await one('SELECT * FROM users WHERE id = ?', [id]);
if (!u) throw notFound();
if (id === a.user.id) throw forbidden('Eigenes Konto kann hier nicht geändert werden', 'SELF_CHANGE');
const privileged = (r: unknown) => r === 'admin' || r === 'superadmin';
if ((privileged(u.staff_role) || privileged(b.staffRole)) && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren', 'PRIVILEGED_ONLY');
if (b.staffRole && u.kind !== 'staff') throw badRequest('Nur für Mitarbeiter');
if (b.status === 'active' && u.status === 'invited') throw badRequest('Eingeladene Benutzer aktivieren sich selbst');
await run('UPDATE users SET status = COALESCE(?, status), staff_role = COALESCE(?, staff_role) WHERE id = ?', [b.status ?? null, b.staffRole ?? null, id]);
if (b.status === 'disabled') await run('UPDATE sessions SET revoked_at = UTC_TIMESTAMP(3) WHERE user_id = ? AND revoked_at IS NULL', [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'user.update', resourceType: 'user', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), before: { status: u.status, staffRole: u.staff_role }, after: b });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,14 @@
import type { KcModule } from '../core/module.js';
import { systemModule } from './system/index.js';
import { identityModule } from './identity/index.js';
import { customersModule } from './customers/index.js';
import { auditModule } from './audit/index.js';
import { connectorsModule } from './connectors/index.js';
import { resourcesModule } from './resources/index.js';
import { domainsModule } from './domains/index.js';
import { catalogModule } from './catalog/index.js';
import { ordersModule } from './orders/index.js';
import { backupModule } from './backup/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, backupModule];

View file

@ -0,0 +1,213 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { PoolConnection } from 'mysql2/promise';
import { calculatePrice } from '@kc/platform/pricing';
import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine';
import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms';
import { one, query, run, tx } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { enqueue } from '../../core/jobs.js';
import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js';
import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js';
import { can, canInOrg } from '../../core/policy.js';
import type { KcModule } from '../../core/module.js';
const j = (v: unknown, d: unknown = null) => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : v);
async function nextNumber(c: PoolConnection, name: 'order' | 'contract', prefix: string): Promise<string> {
await run('UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = ?', [name], c);
return `${prefix}-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
}
const orderView = (o: any, items: any[] = []) => ({
id: o.id, number: o.number, orgId: o.org_id, orgName: o.org_name, status: o.status, placedVia: o.placed_via, approvalRequired: !!o.approval_required, note: o.note,
failureNote: o.failure_note, failureAmbiguous: !!o.failure_ambiguous, createdAt: o.created_at, approvedAt: o.approved_at,
items: items.map((i) => ({ id: i.id, quantity: i.quantity, discountBp: i.discount_bp, snapshot: j(i.snapshot_json), contractId: i.contract_id ?? null, contractNumber: i.contract_number ?? null })),
});
const contractView = (c: any) => ({
id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, status: c.status, productName: c.product_name, startedAt: c.started_at, termEnd: c.term_end, renewal: c.renewal, renewalTermMonths: c.renewal_term_months,
noticeDays: c.notice_days, cancelRequestedAt: c.cancel_requested_at, cancelEffectiveAt: c.cancel_effective_at, cancelledAt: c.cancelled_at, resourceId: c.resource_id, price: j(c.price_snapshot_json), createdAt: c.created_at,
});
const ORDER_SQL = 'SELECT o.*, g.name AS org_name FROM orders o JOIN organizations g ON g.id = o.org_id';
async function loadOrder(id: string) {
const o = await one(`${ORDER_SQL} WHERE o.id = ?`, [id]);
if (!o) return null;
const items = await query('SELECT oi.*, c.id AS contract_id, c.number AS contract_number FROM order_items oi LEFT JOIN contracts c ON c.order_item_id = oi.id WHERE oi.order_id = ?', [id]);
return { o, items };
}
const CONTRACT_SQL = `SELECT c.*, g.name AS org_name, JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name FROM contracts c JOIN organizations g ON g.id = c.org_id`;
/** Wendet ein Ereignis des Bestell-Statusautomaten an (atomar über den erwarteten Ausgangszustand). */
async function orderEvent(c: PoolConnection | undefined, id: string, from: string, ev: OrderEvent, extra: { sql?: string; params?: unknown[] } = {}): Promise<string> {
const to = transition(ORDER_MACHINE, from as never, ev);
const r = await run(`UPDATE orders SET status = ?${extra.sql ? ', ' + extra.sql : ''} WHERE id = ? AND status = ?`, [to, ...(extra.params ?? []), id, from], c);
if (!r.affectedRows) throw conflict('Die Bestellung wurde inzwischen geändert. Bitte neu laden.', 'STALE_STATE');
return to;
}
const startProvisioning = (orderId: string, key: string, correlationId: string) => enqueue('order.provision', { orderId }, { idempotencyKey: key, correlationId });
const orderAccess = (a: AuthContext, orgId: string) => canInOrg(a.principal, orgId, 'orders.read', 'orders.read');
export const ordersModule: KcModule = {
name: 'orders',
permissions: {
staff: {
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
},
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
},
register(app: FastifyInstance) {
// ---- Bestellung anlegen ------------------------------------------------
app.post('/orders', async (req) => {
const a = requireAuth(req);
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
const staff = can(a.principal, 'orders.write');
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
if (!org) throw notFound();
if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann nichts bestellt werden', 'ORG_INACTIVE');
const lines: { p: any; it: { productId: string; quantity: number; discountBp: number }; snapshot: any }[] = [];
for (const it of b.items) {
const p = await one(`SELECT p.*, v.id AS vid, v.version, v.name AS vname, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.currency, v.billing_interval, v.term_months, v.renewal, v.renewal_term_months, v.notice_days
FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?`, [it.productId]);
if (!p || p.status !== 'active') throw badRequest('Produkt nicht verfügbar', 'PRODUCT_UNAVAILABLE');
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
}
const approvalRequired = !staff && lines.some((l) => !!l.p.requires_approval); // Personal bestellt = freigegeben
const orderId = randomUUID();
const number = await tx(async (c) => {
const n = await nextNumber(c, 'order', 'B');
await run('INSERT INTO orders (id, number, org_id, status, placed_by, placed_via, approval_required, approved_by, approved_at, note) VALUES (?,?,?,?,?,?,?,?,?,?)',
[orderId, n, b.orgId, approvalRequired ? 'pending_approval' : 'approved', a.user.id, staff ? 'staff' : 'customer', approvalRequired ? 1 : 0, approvalRequired ? null : a.user.id, approvalRequired ? null : new Date(), b.note ?? null], c);
for (const l of lines) {
const itemId = randomUUID();
await run('INSERT INTO order_items (id, order_id, product_version_id, quantity, discount_bp, snapshot_json) VALUES (?,?,?,?,?,?)', [itemId, orderId, l.snapshot.productVersionId, l.it.quantity, l.it.discountBp, JSON.stringify(l.snapshot)], c);
await run("INSERT INTO contracts (id, number, org_id, order_item_id, product_version_id, status, renewal, renewal_term_months, notice_days, price_snapshot_json) VALUES (?,?,?,?,?,'pending',?,?,?,?)",
[randomUUID(), await nextNumber(c, 'contract', 'V'), b.orgId, itemId, l.snapshot.productVersionId, l.snapshot.terms.renewal, l.snapshot.terms.renewalTermMonths, l.snapshot.terms.noticeDays, JSON.stringify(l.snapshot)], c);
}
if (!approvalRequired) { await orderEvent(c, orderId, 'approved', 'start_provisioning'); await startProvisioning(orderId, `provision:${orderId}:1`, req.correlationId); }
return n;
});
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'order.create', resourceType: 'order', resourceId: orderId, correlationId: req.correlationId, ip: clientIp(req), after: { number, approvalRequired, items: lines.map((l) => ({ sku: l.snapshot.sku, quantity: l.it.quantity, discountBp: l.it.discountBp })) } });
return { id: orderId, number, status: approvalRequired ? 'pending_approval' : 'provisioning' };
});
app.get('/orders', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'orders.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && !orgs.length) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`o.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.status) { where.push('o.status = ?'); params.push(q.status); }
const rows = await query(`${ORDER_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY o.created_at DESC LIMIT 300`, params);
return rows.map((o) => orderView(o));
});
app.get('/orders/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id);
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
return orderView(r.o, r.items);
});
// ---- Freigabe, Ablehnung, Storno, Wiederholung -------------------------
app.post('/admin/orders/:id/approve', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id); if (!r) throw notFound();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'approve', { sql: 'approved_by = ?, approved_at = ?', params: [a.user.id, new Date()] }); await orderEvent(c, id, 'approved', 'start_provisioning'); await startProvisioning(id, `provision:${id}:1`, req.correlationId); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.approve', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'provisioning' };
});
app.post('/admin/orders/:id/reject', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ reason: z.string().trim().max(500).optional() }).parse(req.body ?? {});
const r = await loadOrder(id); if (!r) throw notFound();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'reject', { sql: 'failure_note = ?', params: [b.reason ?? null] }); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.reject', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason } });
return { status: 'rejected' };
});
app.post('/orders/:id/cancel', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadOrder(id);
if (!r || !orderAccess(a, r.o.org_id)) throw notFound();
// Kunden dürfen nur ihre noch nicht freigegebene Bestellung zurückziehen
const allowed = can(a.principal, 'orders.write') || (r.o.status === 'pending_approval' && canInOrg(a.principal, r.o.org_id, 'orders.create', 'orders.write'));
if (!allowed) throw forbidden();
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'cancel'); await run("UPDATE contracts SET status = 'cancelled', cancelled_at = UTC_TIMESTAMP(3) WHERE order_item_id IN (SELECT id FROM order_items WHERE order_id = ?) AND status = 'pending'", [id], c); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.cancel', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'cancelled' };
});
/** Fehlgeschlagene Bereitstellung erneut starten. Bei unklarem Ausgang beim Anbieter ist eine ausdrückliche Bestätigung nötig (Doppelanlage!). */
app.post('/admin/orders/:id/retry', async (req) => {
const a = requirePermission(req, 'orders.approve');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ confirmChecked: z.boolean().default(false) }).parse(req.body ?? {});
const r = await loadOrder(id); if (!r) throw notFound();
if (r.o.failure_ambiguous && !b.confirmChecked) throw badRequest('Bitte bestätigen, dass beim Anbieter geprüft wurde, dass nichts angelegt wurde', 'CONFIRM_REQUIRED');
await tx(async (c) => { await orderEvent(c, id, r.o.status, 'retry', { sql: 'failure_note = NULL, failure_ambiguous = 0' }); await startProvisioning(id, `provision:${id}:retry:${Date.now()}`, req.correlationId); });
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.o.org_id, action: 'order.retry', resourceType: 'order', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ambiguousConfirmed: b.confirmChecked } });
return { status: 'provisioning' };
});
// ---- Verträge ------------------------------------------------------------
app.get('/contracts', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), status: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'contracts.read');
const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId);
if (orgs && !orgs.length) return [];
const where: string[] = []; const params: unknown[] = [];
if (orgs) { where.push(`c.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); }
if (q.status) { where.push('c.status = ?'); params.push(q.status); }
return (await query(`${CONTRACT_SQL} ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY c.created_at DESC LIMIT 300`, params)).map(contractView);
});
app.get('/contracts/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
});
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
app.post('/contracts/:id/cancel', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ immediate: z.boolean().default(false) }).parse(req.body ?? {});
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
if (b.immediate && !can(a.principal, 'contracts.write')) throw forbidden('Sofortige Beendigung nur durch das Personal', 'STAFF_ONLY');
if (!['active', 'suspended'].includes(c.status)) throw badRequest('Dieser Vertrag kann nicht gekündigt werden', 'NOT_CANCELLABLE');
if (c.cancel_requested_at) throw conflict('Die Kündigung wurde bereits eingereicht', 'ALREADY_CANCELLED');
transition(CONTRACT_MACHINE, c.status, 'cancel'); // Prüfung, dass die Kündigung im Zustand erlaubt ist
const now = new Date();
const effective = b.immediate ? now : effectiveCancelDate(now, { termEnd: c.term_end ? new Date(c.term_end) : null, renewal: c.renewal, renewalTermMonths: Number(c.renewal_term_months), noticeDays: Number(c.notice_days) });
await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ? AND cancel_requested_at IS NULL', [now, effective, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { effectiveAt: effective.toISOString(), immediate: b.immediate } });
return { cancelEffectiveAt: effective.toISOString() };
});
app.post('/contracts/:id/cancel/revoke', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
if (!canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write')) throw forbidden();
if (!c.cancel_requested_at || new Date(c.cancel_effective_at) <= new Date()) throw badRequest('Keine widerrufbare Kündigung vorhanden', 'NOT_REVOCABLE');
await run("UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ? AND status IN ('active','suspended')", [id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,258 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { randomUUID, createHash } from 'node:crypto';
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
import { encrypt } from '../../core/crypto.js';
import { CHILD_MANAGE, CUSTOMER_ACTIONS } from '../../core/actions.js';
import { rl } from '../../core/config.js';
import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk';
import { one, query, run } from '../../core/db.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';
const STALE_FACTOR = 3;
const json = <T>(v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T));
/** Ressource + Instanzzustand; "stale" = Provider gestört oder Daten älter als 3 Abgleichintervalle. */
async function loadResource(id: string) {
return one('SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.id = ?', [id]);
}
function view(r: any, staff: boolean, a?: AuthContext) {
const stale = r.health !== 'ok' || Date.now() - new Date(r.synced_at).getTime() > STALE_FACTOR * r.sync_interval_sec * 1000;
return {
id: r.id, type: r.type, name: r.name, state: r.state, orgId: r.org_id, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
canReveal: a ? canReveal(r, a) : undefined,
stale, staleReason: r.health !== 'ok' ? (r.health_message ?? 'Der Dienst ist derzeit nicht erreichbar.') : stale ? 'Die Daten sind älter als erwartet.' : null,
...(staff ? { instance: r.instance_name, connector: r.connector_key, externalRef: r.external_ref } : {}),
};
}
/** Erlaubte Aktionen = Connector-Fähigkeit ∧ Rolle ∧ Ressourcenregel (Kunden nur freigegebene). */
function allowedActions(r: any, a: AuthContext): ActionName[] {
const caps = json<string[]>(r.capabilities_json, []);
const supported = ACTIONS.filter((x) => caps.includes(ACTION_CAPABILITY[x]) && !DESTRUCTIVE_ACTIONS.has(x));
if (r.health !== 'ok' || !r.enabled) return [];
if (can(a.principal, 'resources.write')) return supported;
const orgOk = r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write');
return orgOk ? supported.filter((x) => json<string[]>(r.customer_actions, []).includes(x)) : [];
}
const childCapsTop = (r: any): string[] => json<string[]>(r.capabilities_json, []);
const canLoginTop = (r: any, a: AuthContext): boolean => childCapsTop(r).includes('sso.login') && r.health === 'ok' && !!r.enabled && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login')));
/** Zugangsdaten/Schlüssel anzeigen: Personal mit Schreibrecht oder Inhaber/Admin der zugehörigen Organisation; Anbieter muss es unterstützen und erreichbar sein. */
function canReveal(r: any, a: AuthContext): boolean {
const caps = json<string[]>(r.capabilities_json, []);
if (!caps.includes('secret.reveal') || !r.enabled) return false;
return can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'));
}
function access(a: AuthContext, r: any): boolean {
return can(a.principal, 'resources.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.read', 'resources.read'));
}
export const resourcesModule: KcModule = {
name: 'resources',
permissions: {
staff: { support: ['resources.read'], accounting: ['resources.read'], admin: ['resources.read', 'resources.write'], superadmin: ['resources.read', 'resources.write'] },
org: { owner: ['resources.read', 'resources.manage'], admin: ['resources.read', 'resources.manage'], member: ['resources.read'] },
},
register(app: FastifyInstance) {
app.get('/resources', async (req) => {
const a = requireAuth(req);
const q = z.object({ org: z.string().uuid().optional(), unassigned: z.enum(['1']).optional(), type: z.string().max(30).optional() }).parse(req.query);
const staff = can(a.principal, 'resources.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.unassigned && staff) where.push('r.org_id IS NULL');
if (q.type) { where.push('r.type = ?'); params.push(q.type); }
const rows = await query(`SELECT r.*, i.connector_key, i.name AS instance_name, i.health, i.health_message, i.sync_interval_sec, i.capabilities_json, i.enabled FROM resources r JOIN connector_instances i ON i.id = r.instance_id ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY r.name LIMIT 500`, params);
return rows.map((r) => view(r, staff, a));
});
app.get('/resources/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
const staff = can(a.principal, 'resources.read');
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
});
app.post('/resources/:id/actions', async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend']), days: z.number().int().min(1).max(3650).optional() }).parse(req.body);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!allowedActions(r, a).includes(b.action)) throw forbidden('Diese Aktion ist für diese Ressource nicht verfügbar', 'ACTION_NOT_ALLOWED');
const params: Record<string, unknown> = {};
if (b.action === 'extend') {
if (!b.days) throw badRequest('Anzahl Tage fehlt');
const from = r.valid_until && new Date(r.valid_until) > new Date() ? new Date(r.valid_until) : new Date();
params.until = new Date(from.getTime() + b.days * 86400000).toISOString(); // absoluter Zielwert => bei Wiederholung wirkungsgleich
params.days = b.days;
}
// Idempotenz: Header vom Client (pro Bestätigungsdialog), sonst Hash aus Ressource/Aktion/Parameter im Minutenfenster
const hdr = req.headers['idempotency-key'];
const key = `act:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${b.action}|${JSON.stringify(params)}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
const jobId = existing?.id ?? randomUUID();
if (!existing) {
try {
await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.execute', JSON.stringify({ resourceId: id, action: b.action, params, actorUserId: a.user.id }), key, req.correlationId]);
} catch (e) {
if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; // parallele Doppelanfrage: bestehenden Auftrag zurückgeben
const dup = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
return reply.code(202).send({ jobId: dup!.id, duplicate: true });
}
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.${b.action}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, params } });
}
return reply.code(202).send({ jobId, duplicate: !!existing });
});
/** Schlüssel/Zugangsdaten auf Abruf: live beim Anbieter gelesen, nie gespeichert oder protokolliert (nur DASS abgerufen wurde). */
app.post('/resources/:id/reveal', { config: rl(10, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canReveal(r, a)) throw forbidden('Der Zugriff auf Zugangsdaten ist für diese Ressource nicht möglich', 'REVEAL_FORBIDDEN');
let items: { label: string; value: string }[];
try {
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
if (!connector.reveal) throw badRequest('Nicht unterstützt', 'NOT_SUPPORTED');
items = await connector.reveal(ctx, r.external_ref);
} catch (e) {
if (e instanceof ConnectorError) throw new AppError(502, 'CONNECTOR_ERROR', `Beim Anbieter konnte nichts gelesen werden: ${e.userMessage}`);
throw e;
}
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.reveal', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { labels: items.map((i) => i.label) } });
reply.header('cache-control', 'no-store');
return { items, hideAfterSec: 60 };
});
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
const kindParam = z.object({ id: z.string().uuid(), kind: z.enum(KINDS) });
const childCaps = (r: any): string[] => json<string[]>(r.capabilities_json, []);
/** Schreiben erlaubt: Personal mit Schreibrecht ODER Inhaber/Admin der Organisation, wenn das Produkt es für diese Art freigibt. */
const canManageKind = (r: any, a: AuthContext, kind: string): boolean => {
if (kind === 'certificate') return false;
if (r.health !== 'ok' || !r.enabled || !childCaps(r).includes('children.write')) return false;
if (can(a.principal, 'resources.write')) return true;
const need = CHILD_MANAGE[kind];
return !!need && !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes(need);
};
const providerError = (e: unknown): never => { if (e instanceof ConnectorError) throw new AppError(e.code === 'NOT_FOUND' ? 404 : 502, 'CONNECTOR_ERROR', e.code === 'INVALID_INPUT' ? e.message : `Der Anbieter meldet: ${e.userMessage}`); throw e; };
app.get('/resources/:id/children', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!childCaps(r).includes('children.read')) return { kinds: [], panelLogin: false };
try {
const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId);
const kinds = await connector.children!.kinds(ctx, r.external_ref);
return { kinds: kinds.map((k) => ({ kind: k.kind, canWrite: k.canWrite && canManageKind(r, a, k.kind) })), panelLogin: canLogin(r, a) };
} catch (e) { return providerError(e); }
});
app.get('/resources/:id/children/:kind', { config: rl(60, '1 minute') }, async (req) => {
const a = requireAuth(req);
const { id, kind } = kindParam.parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r) || !childCaps(r).includes('children.read')) throw notFound();
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); return await connector.children!.list(ctx, r.external_ref, kind as ChildKind); }
catch (e) { return providerError(e); }
});
/** Änderung an einem Unterobjekt: läuft als persistenter Auftrag; Passwörter nur verschlüsselt im Auftrag, nach der Ausführung entfernt. */
app.post('/resources/:id/children/:kind', { config: rl(30, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id, kind } = kindParam.parse(req.params);
const b = z.object({ op: z.enum(['create', 'update', 'delete']), id: z.string().max(40).optional(), data: z.record(z.string(), z.unknown()).default({}), password: z.string().max(128).optional() }).parse(req.body);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canManageKind(r, a, kind)) throw forbidden('Diese Änderung ist für diese Ressource nicht möglich', 'ACTION_NOT_ALLOWED');
if ((b.op === 'update' || b.op === 'delete') && !b.id) throw badRequest('Objekt fehlt', 'ID_REQUIRED');
const needsPw = b.op === 'create' && ['email', 'database', 'ftp'].includes(kind);
if (needsPw && !b.password) throw badRequest('Bitte ein Passwort angeben.', 'PASSWORD_REQUIRED');
if (b.password && (b.password.length < 12 || /[\0\r\n]/.test(b.password))) throw badRequest('Das Passwort muss mindestens 12 Zeichen lang sein.', 'WEAK_PASSWORD');
if (JSON.stringify(b.data).length > 4000) throw badRequest('Eingabe zu groß', 'TOO_LARGE');
const hdr = req.headers['idempotency-key'];
const key = `child:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${kind}|${JSON.stringify(b.data)}|${b.op}|${b.id ?? ''}|${Math.floor(Date.now() / 60000)}`).digest('hex').slice(0, 40)}`;
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
if (existing) return reply.code(202).send({ jobId: existing.id, duplicate: true });
const jobId = randomUUID();
const payload = { resourceId: id, kind, op: b.op, id: b.id, data: b.data, actorUserId: a.user.id, destructive: b.op === 'delete', ...(b.password ? { secretEnc: encrypt(JSON.stringify({ password: b.password })) } : {}) };
try { await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)', [jobId, 'connector.child', JSON.stringify(payload), key, req.correlationId]); }
catch (e) { if ((e as { code?: string }).code !== 'ER_DUP_ENTRY') throw e; const d = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]); return reply.code(202).send({ jobId: d!.id, duplicate: true }); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `resource.child.${kind}.${b.op}.request`, resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId, id: b.id, data: b.data } });
return reply.code(202).send({ jobId, duplicate: false });
});
const canLogin = (r: any, a: AuthContext): boolean => {
if (!childCaps(r).includes('sso.login') || r.health !== 'ok' || !r.enabled) return false;
if (can(a.principal, 'resources.write')) return true;
return !!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write') && json<string[]>(r.customer_actions, []).includes('panel.login');
};
/** Panel-Login: kurzlebiger Link, nie gespeichert, Abruf im Audit. */
app.post('/resources/:id/login', { config: rl(10, '1 minute') }, async (req, reply) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const r = await loadResource(id);
if (!r || !access(a, r)) throw notFound();
if (!canLogin(r, a)) throw forbidden('Der Panel-Login ist für diese Ressource nicht möglich', 'LOGIN_FORBIDDEN');
let out: { url: string; validForSec: number };
try { const { connector, ctx } = await loadInstance(r.instance_id, req.correlationId); out = await connector.loginUrl!(ctx, r.external_ref); } catch (e) { return providerError(e); }
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.login', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req) });
reply.header('cache-control', 'no-store'); return out;
});
app.get('/jobs/:id', async (req) => {
const a = requireAuth(req);
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const j = await one("SELECT id, type, status, attempts, max_attempts, last_error, created_at, updated_at, JSON_VALUE(payload, '$.resourceId') AS resource_id FROM jobs WHERE id = ?", [id]);
if (!j || !j.resource_id) throw notFound();
const r = await loadResource(j.resource_id);
if (!r || !access(a, r)) throw notFound();
return { id: j.id, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, createdAt: j.created_at, updatedAt: j.updated_at };
});
app.get('/admin/jobs', async (req) => {
requirePermission(req, 'jobs.read');
const q = z.object({ status: z.string().max(30).optional() }).parse(req.query);
return (await query('SELECT id, type, status, attempts, max_attempts, last_error, run_at, created_at, updated_at, correlation_id FROM jobs WHERE (? IS NULL OR status = ?) ORDER BY created_at DESC LIMIT 200', [q.status ?? null, q.status ?? null]))
.map((j) => ({ id: j.id, type: j.type, status: j.status, attempts: j.attempts, maxAttempts: j.max_attempts, error: j.last_error, runAt: j.run_at, createdAt: j.created_at, correlationId: j.correlation_id }));
});
/** Kontrollierte manuelle Wiederholung: nur für Jobs in needs_review/failed, nie für destruktive Aktionen. */
app.post('/admin/jobs/:id/retry', async (req) => {
const a = requirePermission(req, 'resources.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const j = await one("SELECT id, status, (JSON_VALUE(payload, '$.destructive') IN ('1','true') OR JSON_VALUE(payload, '$.action') = 'terminate') AS destructive FROM jobs WHERE id = ?", [id]);
if (!j) throw notFound();
if (!['needs_review', 'failed'].includes(j.status)) throw badRequest('Nur fehlgeschlagene Aufträge können wiederholt werden', 'NOT_RETRYABLE');
if (Number(j.destructive) === 1) throw forbidden('Destruktive Aufträge werden nicht automatisch wiederholt; bitte Ergebnis beim Provider prüfen', 'DESTRUCTIVE');
await run("UPDATE jobs SET status='retrying', attempts=0, run_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id = ?", [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'job.retry', resourceType: 'job', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { status: 'ok' };
});
app.patch('/admin/resources/:id', async (req) => {
const a = requirePermission(req, 'resources.write');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ orgId: z.string().uuid().nullable().optional(), customerActions: z.array(z.enum(CUSTOMER_ACTIONS)).optional() }).parse(req.body);
const r = await loadResource(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 = ?, customer_actions = ? WHERE id = ?', [b.orgId === undefined ? r.org_id : b.orgId, JSON.stringify(b.customerActions ?? json(r.customer_actions, [])), id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id, customerActions: json(r.customer_actions, []) }, after: b });
return { status: 'ok' };
});
},
};

View file

@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import { one } from '../../core/db.js';
import { requirePermission } from '../../core/auth.js';
import { query } from '../../core/db.js';
import { readFile } from 'node:fs/promises';
import type { KcModule } from '../../core/module.js';
/** Backup-Zustand aus der Statusdatei des Backup-Laufs (siehe docs/betrieb-backup-restore.md). Ohne Datei: nicht eingerichtet. */
async function backupState() {
const file = process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json';
let st: any; try { st = JSON.parse(await readFile(file, 'utf8')); } catch { return { configured: false as const }; }
const hours = (iso?: string) => (iso ? (Date.now() - new Date(iso).getTime()) / 3600000 : null);
const run = st.lastRun; const test = st.lastRestoreTest;
return {
configured: true as const, lastRunAt: run?.at ?? null, ok: !!run?.ok, ageHours: hours(run?.at), stale: !run || !run.ok || (hours(run.at) ?? 999) > 26, error: run?.error ?? null,
file: run?.file ?? null, sizeBytes: run?.sizeBytes ?? null, targets: (run?.targets ?? []).map((t: any) => ({ name: t.name, ok: t.ok })),
lastRestoreTestAt: test?.at ?? null, restoreOk: test ? !!test.ok : null, restoreStale: !test || !test.ok || (hours(test.at) ?? 999) > 24 * 10, restoreError: test?.error ?? null,
};
}
export const systemModule: KcModule = {
name: 'system',
register(app: FastifyInstance) {
app.get('/health', async () => ({ status: 'ok' })); // Liveness
app.get('/ready', async (_req, reply) => { // Readiness: DB erreichbar
try { await one('SELECT 1 AS ok'); return { status: 'ready' }; } catch { return reply.code(503).send({ status: 'db_unavailable' }); }
});
app.get('/admin/system', async (req) => {
requirePermission(req, 'jobs.read');
const jobs = await query('SELECT status, COUNT(*) AS n FROM jobs GROUP BY status');
const oldest = await one('SELECT MIN(run_at) AS t FROM jobs WHERE status IN (\'scheduled\',\'retrying\')');
return { jobs: Object.fromEntries(jobs.map((j) => [j.status, Number(j.n)])), oldestPendingJob: oldest?.t ?? null, serverTime: new Date().toISOString(), backup: await backupState() };
});
},
};

209
apps/api/src/ops/backup.ts Normal file
View file

@ -0,0 +1,209 @@
import { spawn } from 'node:child_process';
import { createReadStream, createWriteStream, existsSync } from 'node:fs';
import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import mysql from 'mysql2/promise';
import { config } from '../core/config.js';
import { query, one } from '../core/db.js';
import { audit, verifyAuditChain } from '../core/audit.js';
import { enqueue } from '../core/jobs.js';
import { fileName, parseName, selectDeletions, type Keep } from './retention.js';
import { getBackupPassword } from './settings.js';
import { buildRemote, loadTargets, friendly, type Remote } from './targets.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 }
export function loadBackupConfig(env = process.env): BackupConfig {
return {
dir: env.BACKUP_DIR ?? '/var/backups/kundencenter', recipient: env.BACKUP_AGE_RECIPIENT || null, identity: env.BACKUP_AGE_IDENTITY || null,
remotes: (env.BACKUP_REMOTES ?? '').split(',').map((s) => s.trim()).filter(Boolean), rclone: env.BACKUP_RCLONE_BIN ?? 'rclone', rcloneConfig: env.RCLONE_CONFIG || null,
keep: { daily: Number(env.BACKUP_KEEP_DAILY ?? 14), weekly: Number(env.BACKUP_KEEP_WEEKLY ?? 8), monthly: Number(env.BACKUP_KEEP_MONTHLY ?? 12) },
statusFile: env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json', envDir: env.KC_BACKUP_ENV_DIR ?? '/etc/kundencenter',
};
}
/** Externes Programm ohne Shell starten. Fehlertext enthält nur stderr (ohne Umgebung/Passwörter). */
function run(cmd: string, args: string[], o: { env?: Record<string, string>; stdin?: string; stdinText?: string; stdout?: string; input?: string } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/root', ...(o.env ?? {}) }, stdio: [o.stdin || o.stdinText !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = ''; let err = '';
if (o.stdout) p.stdout!.pipe(createWriteStream(o.stdout, { mode: 0o600 })); else p.stdout!.on('data', (d) => (out += d));
p.stderr!.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); });
if (o.stdin) createReadStream(o.stdin).pipe(p.stdin!); else if (o.stdinText !== undefined) { p.stdin!.on('error', () => undefined); p.stdin!.end(o.stdinText); }
p.on('error', (e) => reject(new Error(`${basename(cmd)} konnte nicht gestartet werden: ${e.message}`)));
p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`${basename(cmd)} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-3).join(' | ')}`))));
});
}
/** Passwortverschlüsselung mit gpg (AES-256, Integritätsschutz). Das Passwort geht über stdin, nie über Argumente. Eigenes Arbeitsverzeichnis, kein Agent-Rückstand. */
async function gpgEncrypt(work: string, input: string, output: string, password: string): Promise<void> {
const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700);
await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '--symmetric', '--cipher-algo', 'AES256', '--s2k-mode', '3', '--s2k-count', '65011712', '--s2k-digest-algo', 'SHA512', '-o', output, input], { stdinText: password });
}
async function gpgDecrypt(work: string, input: string, output: string, password: string): Promise<void> {
const home = await mkdtemp(join(work, 'gnupg-')); await chmod(home, 0o700);
await run('gpg', ['--homedir', home, '--batch', '--yes', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', '-o', output, input], { stdinText: password });
}
const sha256 = async (f: string) => { const h = createHash('sha256'); for await (const c of createReadStream(f)) h.update(c); return h.digest('hex'); };
const dbEnv = () => ({ MYSQL_PWD: config.db.password });
const dbArgs = () => ['-h', config.db.host, '-P', String(config.db.port), '-u', config.db.user];
const rcloneArgs = (c: BackupConfig) => (c.rcloneConfig ? ['--config', c.rcloneConfig] : []);
export interface RunSummary { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; error?: string }
export interface Status {
running?: { action: 'backup' | 'restore-test'; since: string } | null;
history?: RunSummary[];
lastRun?: { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; targets: { name: string; ok: boolean; error?: string }[]; error?: string };
lastRestoreTest?: { at: string; ok: boolean; file?: string; durationMs: number; checks: Record<string, string | number | boolean>; error?: string };
}
export async function readStatus(c: BackupConfig): Promise<Status> { try { return JSON.parse(await readFile(c.statusFile, 'utf8')); } catch { return {}; } }
async function writeStatus(c: BackupConfig, patch: Partial<Status>): Promise<void> {
const s = { ...(await readStatus(c)), ...patch };
if (patch.lastRun) { const r = patch.lastRun; s.history = [{ at: r.at, ok: r.ok, file: r.file, sizeBytes: r.sizeBytes, durationMs: r.durationMs, label: r.label, error: r.error }, ...((await readStatus(c)).history ?? [])].slice(0, 30); } const tmp = `${c.statusFile}.tmp`;
await mkdir(join(c.statusFile, '..'), { recursive: true }); await writeFile(tmp, JSON.stringify(s, null, 2), { mode: 0o644 }); await rename(tmp, c.statusFile);
}
const alertOnce = (event: string, detail: string) => enqueue('discord.notify', { event, detail: detail.slice(0, 200) }, { idempotencyKey: `${event}:${new Date().toISOString().slice(0, 13)}` }).catch(() => undefined);
const COUNT_TABLES = ['users', 'organizations', 'memberships', 'orders', 'contracts', 'products', 'resources', 'connector_instances', 'audit_events', 'jobs'];
async function tableCounts(q: (sql: string) => Promise<any[]>): Promise<Record<string, number>> {
const out: Record<string, number> = {};
for (const t of COUNT_TABLES) out[t] = Number((await q(`SELECT COUNT(*) AS n FROM \`${t}\``))[0].n);
return out;
}
/** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */
export async function runBackup(c: BackupConfig, now = new Date()): Promise<NonNullable<Status['lastRun']>> {
const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record<string, string> }[] = []; const built: Remote[] = [];
const pw = await getBackupPassword().catch(() => null); const name = fileName(now, pw ? 'gpg' : 'age');
await mkdir(c.dir, { recursive: true, mode: 0o700 });
await writeStatus(c, { running: { action: 'backup', since: now.toISOString() } }).catch(() => undefined);
const work = await mkdtemp(join(c.dir, '.work-'));
try {
await mkdir(join(work, 'config'));
await run('mysqldump', [...dbArgs(), '--single-transaction', '--routines', '--triggers', '--events', '--no-tablespaces', '--default-character-set=utf8mb4', config.db.database], { env: dbEnv(), stdout: join(work, 'db.sql') });
// Konfiguration inkl. Master-Schlüssel (ohne den privaten Backup-Schlüssel!): ohne KC_SECRET_KEY sind TOTP-/Connector-Geheimnisse unlesbar
// Nur die Dateien, die das Kundencenter zum Betrieb braucht (nicht z. B. Plane-Zugang, nie den privaten Backup-Schlüssel)
for (const f of ['app.env', 'db.env', 'discord.env']) if (existsSync(join(c.envDir, f))) await copyFile(join(c.envDir, f), join(work, 'config', f));
const counts = await tableCounts((sql) => query(sql));
const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string);
await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2));
await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']);
const out = join(c.dir, name);
if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password);
else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]);
else throw new Error('Keine Verschlüsselung eingerichtet: bitte unter Einstellungen → Backup ein Passwort festlegen.');
await chmod(out, 0o600); // nur Besitzer
const size = (await stat(out)).size;
if (size < 512) throw new Error('Backup-Datei ist unplausibel klein');
await writeFile(`${out}.sha256`, `${await sha256(out)} ${name}\n`, { mode: 0o600 });
targets.push({ name: `lokal (${c.dir})`, ok: true });
// Ziele: in der Oberfläche definierte (Datenbank) plus ggf. Altbestand aus BACKUP_REMOTES
try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } }
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 } : {} });
const okDests: typeof dests = [];
for (const d of dests) {
try {
const dest = `${d.remote.replace(/\/+$/, '')}/${name}`;
await run(c.rclone, ['copyto', out, dest, '--retries', '3', '--low-level-retries', '5', '--timeout', '120s', '--contimeout', '30s'], { env: d.env });
const ls = JSON.parse(await run(c.rclone, ['lsjson', dest], { env: d.env })) as { Size: number }[];
if (ls[0]?.Size !== size) throw new Error(`Größe am Ziel weicht ab (${ls[0]?.Size ?? 'fehlt'} statt ${size})`);
await run(c.rclone, ['copyto', `${out}.sha256`, `${dest}.sha256`], { env: d.env });
targets.push({ name: d.label, ok: true }); okDests.push(d);
} catch (e) { targets.push({ name: d.label, ok: false, error: friendly(e) }); }
}
await applyRetention(c, now, okDests, targets);
const failed = targets.filter((t) => !t.ok);
const res = { at: now.toISOString(), ok: failed.length === 0, file: name, sizeBytes: size, durationMs: Date.now() - t0, label: parseName(name)?.label, targets, ...(failed.length ? { error: `${failed.length} Ziel(e) fehlgeschlagen` } : {}) };
await writeStatus(c, { lastRun: res, running: null });
await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', resourceId: name, result: res.ok ? 'success' : 'failure', errorClass: res.ok ? undefined : 'target_failed', after: { sizeBytes: size, targets: targets.map((t) => ({ name: t.name, ok: t.ok })) } }).catch(() => undefined);
if (!res.ok) await alertOnce('backup.failed', res.error ?? 'Ziel fehlgeschlagen');
return res;
} catch (e) {
const res = { at: now.toISOString(), ok: false, durationMs: Date.now() - t0, targets, error: (e as Error).message.slice(0, 400) };
await writeStatus(c, { lastRun: res, running: null }).catch(() => undefined);
await audit({ actorType: 'system', action: 'backup.run', resourceType: 'backup', result: 'failure', errorClass: 'backup_error' }).catch(() => undefined);
await alertOnce('backup.failed', res.error);
return res;
} finally { await rm(work, { recursive: true, force: true }); for (const r of built) await r.cleanup().catch(() => undefined); }
}
async function applyRetention(c: BackupConfig, now: Date, dests: { label: string; remote: string; env: Record<string, string> }[], targets: { name: string; ok: boolean; error?: string }[]): Promise<void> {
const localNames = (await readdir(c.dir)).filter((n) => parseName(n));
for (const n of selectDeletions(localNames, now, c.keep)) { await rm(join(c.dir, n), { force: true }); await rm(join(c.dir, `${n}.sha256`), { force: true }); }
for (const d of dests) {
const t = targets.find((x) => x.name === d.label);
try {
const base = d.remote.replace(/\/+$/, '');
const names = (await run(c.rclone, ['lsf', d.remote, '--files-only'], { env: d.env })).split('\n').map((x) => x.trim()).filter(Boolean);
for (const n of selectDeletions(names.filter((x) => parseName(x)), now, c.keep)) { await run(c.rclone, ['deletefile', `${base}/${n}`], { env: d.env }); await run(c.rclone, ['deletefile', `${base}/${n}.sha256`], { env: d.env }).catch(() => undefined); }
} catch (e) { if (t) t.error = `Aufräumen fehlgeschlagen: ${friendly(e)}`; }
}
}
/** Neueste lokale Sicherung finden. */
export async function latestLocal(c: BackupConfig): Promise<string | null> {
const names = (await readdir(c.dir)).map(parseName).filter((x): x is NonNullable<ReturnType<typeof parseName>> => !!x).sort((a, b) => b.at.getTime() - a.at.getTime());
return names[0]?.name ?? null;
}
/**
* Wiederherstellungstest: entschlüsselt eine Sicherung, spielt sie in eine Wegwerf-Datenbank ein und prüft Prüfsumme, Zeilenzahlen,
* Migrationen, Audit-Hash-Kette und ob die gesicherten Geheimnisse mit dem gesicherten Master-Schlüssel entschlüsselbar sind. Produktivdaten bleiben unberührt.
*/
export async function runRestoreTest(c: BackupConfig, file?: string): Promise<NonNullable<Status['lastRestoreTest']>> {
const t0 = Date.now(); const checks: Record<string, string | number | boolean> = {}; const scratch = `${config.db.database}_restoretest`;
const work = await mkdtemp(join(tmpdir(), 'kc-restore-'));
await writeStatus(c, { running: { action: 'restore-test', since: new Date().toISOString() } }).catch(() => undefined);
let name = file;
try {
name = name ?? (await latestLocal(c)) ?? undefined; if (!name) throw new Error('Keine Sicherung gefunden');
const src = join(c.dir, name); checks.datei = name;
const expect = (await readFile(`${src}.sha256`, 'utf8')).split(/\s+/)[0]; checks.pruefsumme = (await sha256(src)) === expect;
if (!checks.pruefsumme) throw new Error('Prüfsumme der Sicherung stimmt nicht');
if (name.endsWith('.gpg')) {
const pw = await getBackupPassword(); if (!pw) throw new Error('Kein Backup-Passwort gespeichert: die Sicherung ist passwortverschlüsselt.');
try { await gpgDecrypt(work, src, join(work, 'archive.tar.gz'), pw.password); } catch { throw new Error('Entschlüsselung fehlgeschlagen: Das gespeicherte Passwort passt nicht zu dieser Sicherung (wurde das Passwort inzwischen geändert?).'); }
} else {
if (!c.identity || !existsSync(c.identity)) throw new Error('Privater Backup-Schlüssel (BACKUP_AGE_IDENTITY) nicht vorhanden');
await run('age', ['-d', '-i', c.identity, '-o', join(work, 'archive.tar.gz'), src]);
}
await run('tar', ['-xzf', join(work, 'archive.tar.gz'), '-C', work]);
const manifest = JSON.parse(await readFile(join(work, 'manifest.json'), 'utf8')); checks.dumpPruefsumme = (await sha256(join(work, 'db.sql'))) === manifest.dumpSha256;
if (!checks.dumpPruefsumme) throw new Error('Prüfsumme des Datenbank-Dumps stimmt nicht');
const admin = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password });
try {
await admin.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await admin.query(`CREATE DATABASE \`${scratch}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
} finally { await admin.end(); }
await run('mysql', [...dbArgs(), scratch], { env: dbEnv(), stdin: join(work, 'db.sql') });
const conn = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password, database: scratch, timezone: 'Z' }); // UTC wie im Betrieb, sonst stimmt die Hash-Kette der Zeitstempel nicht
try {
const q = async (sql: string) => (await conn.query(sql))[0] as any[];
const counts = await tableCounts(q); const mism = Object.entries(manifest.counts as Record<string, number>).filter(([t, n]) => counts[t] !== n);
checks.zeilenzahlen = mism.length === 0; if (mism.length) throw new Error(`Zeilenzahlen weichen ab: ${mism.map(([t]) => t).join(', ')}`);
const mig = (await q('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name); checks.migrationen = JSON.stringify(mig) === JSON.stringify(manifest.migrations); if (!checks.migrationen) throw new Error('Migrationen weichen ab');
const chain = await verifyAuditChain(q); checks.auditKette = chain.brokenAt === null; checks.auditEintraege = chain.checked; if (chain.brokenAt !== null) throw new Error(`Audit-Kette defekt ab Eintrag ${chain.brokenAt}`);
// Geheimnisse mit dem GESICHERTEN Schlüssel entschlüsseln (beweist, dass die Schlüsselsicherung brauchbar ist)
const key = /^KC_SECRET_KEY=(.+)$/m.exec(await readFile(join(work, 'config', 'app.env'), 'utf8'))?.[1];
if (!key) throw new Error('Master-Schlüssel fehlt in der Sicherung');
const { createDecipheriv } = await import('node:crypto');
const dec = (blob: string) => { const [v, iv, tag, ct] = blob.split(':'); const d = createDecipheriv('aes-256-gcm', Buffer.from(key, 'base64'), Buffer.from(iv!, 'base64url')); d.setAuthTag(Buffer.from(tag!, 'base64url')); return Buffer.concat([d.update(Buffer.from(ct!, 'base64url')), d.final()]).length > 0 && v === 'v1'; };
const sample = [...(await q('SELECT secret_enc AS s FROM mfa_totp LIMIT 3')), ...(await q('SELECT secrets_enc AS s FROM connector_instances WHERE secrets_enc IS NOT NULL LIMIT 3'))];
checks.geheimnisseGeprueft = sample.length; checks.geheimnisseOk = sample.every((r) => dec(r.s)); if (!checks.geheimnisseOk) throw new Error('Gesicherte Geheimnisse sind mit dem gesicherten Schlüssel nicht entschlüsselbar');
} finally { await conn.end(); }
const res = { at: new Date().toISOString(), ok: true, file: name, durationMs: Date.now() - t0, checks };
await writeStatus(c, { lastRestoreTest: res, running: null });
await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'success' }).catch(() => undefined);
return res;
} catch (e) {
const res = { at: new Date().toISOString(), ok: false, file: name, durationMs: Date.now() - t0, checks, error: (e as Error).message.slice(0, 400) };
await writeStatus(c, { lastRestoreTest: res, running: null }).catch(() => undefined);
await audit({ actorType: 'system', action: 'backup.restore_test', resourceType: 'backup', resourceId: name, result: 'failure', errorClass: 'restore_test_failed' }).catch(() => undefined);
await alertOnce('restoretest.failed', res.error);
return res;
} finally {
await rm(work, { recursive: true, force: true });
try { const a = await mysql.createConnection({ host: config.db.host, port: config.db.port, user: config.db.user, password: config.db.password }); await a.query(`DROP DATABASE IF EXISTS \`${scratch}\``); await a.end(); } catch { /* Aufräumen best effort */ }
}
}

View file

@ -0,0 +1,26 @@
/** Benennung und Aufbewahrung von Backups (rein, ohne Dateizugriff). Zeiten in UTC. */
export type Label = 'daily' | 'weekly' | 'monthly';
export interface Keep { daily: number; weekly: number; monthly: number }
export const FILE_RE = /^kundencenter-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(daily|weekly|monthly)\.tar\.gz\.(age|gpg)$/;
/** Monatserster = monthly, Sonntag = weekly, sonst daily. */
export const labelFor = (d: Date): Label => (d.getUTCDate() === 1 ? 'monthly' : d.getUTCDay() === 0 ? 'weekly' : 'daily');
const pad = (n: number, l = 2) => String(n).padStart(l, '0');
export const fileName = (d: Date, ext: 'age' | 'gpg' = 'age'): string => `kundencenter-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${labelFor(d)}.tar.gz.${ext}`;
export function parseName(n: string): { name: string; at: Date; label: Label; ext: 'age' | 'gpg' } | null {
const m = FILE_RE.exec(n); if (!m) return null;
return { name: n, at: new Date(Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!)), label: m[7] as Label, ext: m[8] as 'age' | 'gpg' };
}
/**
* Welche Dateien dürfen gelöscht werden? Täglich: keep.daily Tage, wöchentlich: keep.weekly Wochen, monatlich: keep.monthly Monate.
* Fremde Dateinamen werden nie angefasst. Es bleiben immer mindestens `minKeep` Sicherungen erhalten (die neuesten).
*/
export function selectDeletions(names: string[], now: Date, keep: Keep, minKeep = 3): string[] {
const all = names.map(parseName).filter((x): x is NonNullable<ReturnType<typeof parseName>> => !!x);
const ageDays = (d: Date) => (now.getTime() - d.getTime()) / 86400000;
const limit: Record<Label, number> = { daily: keep.daily, weekly: keep.weekly * 7, monthly: keep.monthly * 31 };
const del = all.filter((b) => ageDays(b.at) > limit[b.label]).sort((a, b) => b.at.getTime() - a.at.getTime()); // neueste zuerst
let survivors = all.length - del.length;
while (survivors < minKeep && del.length) { del.shift(); survivors++; } // neueste "Löschkandidaten" behalten
return del.map((b) => b.name);
}

View file

@ -0,0 +1,28 @@
import { one, run } from '../core/db.js';
import { decrypt, encrypt } from '../core/crypto.js';
/** Backup-Passwort: Mindestlänge laut Vorgabe "länger als 10 Zeichen". */
export const MIN_PASSWORD = 11;
export function passwordProblem(pw: string): string | null {
if (pw.length < MIN_PASSWORD) return `Das Passwort muss länger als 10 Zeichen sein (mindestens ${MIN_PASSWORD}).`;
if (pw.length > 200) return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
if (/^(.)\1+$/.test(pw)) return 'Das Passwort darf nicht nur aus einem wiederholten Zeichen bestehen.';
if (/[\r\n\0]/.test(pw)) return 'Das Passwort darf keine Zeilenumbrüche enthalten.';
return null;
}
interface PasswordRecord { password: string; version: number; updatedAt: string }
/** Liefert das gespeicherte Backup-Passwort (mit dem Master-Schlüssel verschlüsselt in der Datenbank) oder null. */
export async function getBackupPassword(): Promise<PasswordRecord | null> {
const r = await one('SELECT value_enc FROM backup_settings WHERE `key` = ?', ['password']);
return r ? (JSON.parse(decrypt(r.value_enc)) as PasswordRecord) : null;
}
export async function passwordMeta(): Promise<{ set: boolean; version: number; updatedAt: string | null }> {
const r = await getBackupPassword(); return { set: !!r, version: r?.version ?? 0, updatedAt: r?.updatedAt ?? null };
}
export async function setBackupPassword(pw: string, userId: string | null): Promise<number> {
const problem = passwordProblem(pw); if (problem) throw new Error(problem);
const cur = await getBackupPassword(); const version = (cur?.version ?? 0) + 1;
await run('INSERT INTO backup_settings (`key`, value_enc, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_by = VALUES(updated_by)',
['password', encrypt(JSON.stringify({ password: pw, version, updatedAt: new Date().toISOString() } satisfies PasswordRecord)), userId]);
return version;
}

View file

@ -0,0 +1,94 @@
import { spawn } from 'node:child_process';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { decrypt } from '../core/crypto.js';
import { query } from '../core/db.js';
export type TargetType = 'sftp' | 'ftp' | 'gdrive' | 'local';
export interface TargetRow { id: string; name: string; type: TargetType; path: string; config: Record<string, any>; secrets: Record<string, string>; hostKey: string | null; enabled: boolean }
/** Ein einsatzbereites Ziel: rclone-Zielangabe + Umgebung (keine Konfigurationsdatei, keine Passwörter in Argumenten). */
export interface Remote { name: string; remote: string; env: Record<string, string>; cleanup: () => Promise<void> }
export async function loadTargets(onlyEnabled = true): Promise<TargetRow[]> {
const rows = await query(`SELECT * FROM backup_targets ${onlyEnabled ? 'WHERE enabled = 1' : ''} ORDER BY name`);
return rows.map((r) => ({
id: r.id, name: r.name, type: r.type, path: r.path, enabled: !!r.enabled, hostKey: r.host_key ?? null,
config: typeof r.config_json === 'string' ? JSON.parse(r.config_json) : r.config_json, secrets: r.secrets_enc ? JSON.parse(decrypt(r.secrets_enc)) : {},
}));
}
/** Kleines Hilfsprogramm mit Zeitlimit; Fehlertext nur aus stderr. */
export function exec(cmd: string, args: string[], o: { env?: Record<string, string>; input?: string; timeoutMs?: number } = {}): Promise<string> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', ...(o.env ?? {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
let out = ''; let err = ''; const timer = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(`${cmd} hat nicht rechtzeitig geantwortet`)); }, o.timeoutMs ?? 60_000);
p.stdout.on('data', (d) => (out += d)); p.stderr.on('data', (d) => { err += d; if (err.length > 4000) err = err.slice(-4000); });
p.on('error', (e) => { clearTimeout(timer); reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`)); });
p.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve(out) : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().split('\n').slice(-2).join(' | ')}`)); });
p.stdin.on('error', () => undefined); p.stdin.end(o.input ?? '');
});
}
const obscure = async (rclone: string, pw: string) => (await exec(rclone, ['obscure', '-'], { input: pw, timeoutMs: 10_000 })).trim();
/** Verständliche Fehlermeldung ohne Zugangsdaten. */
export function friendly(e: unknown): string {
const m = String((e as Error)?.message ?? e);
if (/host key|knownhosts|key mismatch|REMOTE HOST IDENTIFICATION/i.test(m)) return 'Der Server-Schlüssel stimmt nicht mit dem gemerkten überein. Wurde der Server neu aufgesetzt? Dann den Server-Schlüssel neu erfassen.';
if (/auth|permission denied|login|530|password/i.test(m)) return 'Anmeldung fehlgeschlagen (Benutzer, Passwort oder Schlüssel prüfen).';
if (/no such host|lookup|name or service/i.test(m)) return 'Der Servername wurde nicht gefunden.';
if (/refused|unreachable|timed out|timeout|nicht rechtzeitig|i\/o timeout/i.test(m)) return 'Der Server ist nicht erreichbar (Adresse, Port und Firewall prüfen).';
if (/directory not found|not found|no such file/i.test(m)) return 'Der Zielordner wurde nicht gefunden und konnte nicht angelegt werden.';
if (/quota|storage|space/i.test(m)) return 'Kein Speicherplatz mehr am Ziel.';
return m.replace(/\s+/g, ' ').slice(0, 300);
}
export async function buildRemote(t: TargetRow, rclone = 'rclone', hostKeyOverride?: string): Promise<Remote> {
if (t.type === 'local') return { name: t.name, remote: t.path, env: {}, cleanup: async () => undefined };
const rn = `KCT${t.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`; const P = `RCLONE_CONFIG_${rn}_`; const env: Record<string, string> = {};
// Eigenes, temporäres Verzeichnis (Server-Schlüssel, leere rclone-Konfiguration): rclone darf nichts in Benutzerordner schreiben und findet keine fremde Konfiguration
const tmp: string = await mkdtemp(join(tmpdir(), 'kc-rc-')); await writeFile(join(tmp, 'rclone.conf'), '', { mode: 0o600 }); env.RCLONE_CONFIG = join(tmp, 'rclone.conf');
const set = (k: string, v: string | number | boolean | undefined | null) => { if (v !== undefined && v !== null && v !== '') env[`${P}${k}`] = String(v); };
if (t.type === 'sftp') {
set('TYPE', 'sftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 22); set('USER', t.config.user);
if (t.config.authType === 'key') { set('KEY_PEM', (t.secrets.privateKey ?? '').replace(/\r/g, '').trim().replace(/\n/g, '\\n')); if (t.secrets.keyPassphrase) set('KEY_FILE_PASS', await obscure(rclone, t.secrets.keyPassphrase)); }
else if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password));
const hk = hostKeyOverride ?? t.hostKey;
if (hk) { const f = join(tmp, 'known_hosts'); await writeFile(f, `${hk.trim()}\n`, { mode: 0o600 }); set('KNOWN_HOSTS_FILE', f); }
set('DISABLE_HASHCHECK', 'true'); // auch für reine SFTP-Zugänge ohne Shell
} else if (t.type === 'ftp') {
set('TYPE', 'ftp'); set('HOST', t.config.host); set('PORT', t.config.port ?? 21); set('USER', t.config.user);
if (t.secrets.password) set('PASS', await obscure(rclone, t.secrets.password));
if (t.config.tls === 'explicit') set('EXPLICIT_TLS', 'true'); else if (t.config.tls === 'implicit') set('TLS', 'true');
} else if (t.type === 'gdrive') {
set('TYPE', 'drive'); set('SCOPE', t.config.scope ?? 'drive.file'); set('TOKEN', t.secrets.token); set('CLIENT_ID', t.secrets.clientId); set('CLIENT_SECRET', t.secrets.clientSecret);
}
return { name: t.name, remote: `${rn}:${t.path.replace(/^\/+/, '')}`, env, cleanup: async () => { await rm(tmp, { recursive: true, force: true }); } };
}
/** Server-Schlüssel eines SFTP-Ziels holen (Erstkontakt: "Trust on first use"; Fingerabdruck wird angezeigt). */
export async function scanHostKey(host: string, port: number): Promise<{ line: string; fingerprint: string }> {
const out = await exec('ssh-keyscan', ['-T', '10', '-t', 'ed25519,ecdsa,rsa', '-p', String(port), host], { timeoutMs: 20_000 });
const lines = out.split('\n').filter((l) => l && !l.startsWith('#')); if (!lines.length) throw new Error('Der Server hat keinen Schlüssel geliefert (Adresse/Port prüfen).');
const pick = lines.find((l) => l.includes('ssh-ed25519')) ?? lines.find((l) => l.includes('ecdsa')) ?? lines[0]!;
const fp = (await exec('ssh-keygen', ['-lf', '-'], { input: pick + '\n', timeoutMs: 10_000 })).trim().split(/\s+/)[1] ?? '';
return { line: pick, fingerprint: fp };
}
/** Verbindungstest: Ordner anlegen, kleine Testdatei schreiben und wieder löschen (belegt echte Schreibrechte). */
export async function testTarget(t: TargetRow, rclone = 'rclone'): Promise<{ ok: boolean; error?: string; hostKey?: string; fingerprint?: string }> {
let hostKey: string | undefined; let fingerprint: string | undefined;
try {
if (t.type === 'local') { const st = await stat(t.path).catch(() => null); if (!st?.isDirectory()) throw new Error('Der Ordner existiert nicht (bei Netzlaufwerken: ist es eingehängt?)'); }
if (t.type === 'sftp' && !t.hostKey) { const k = await scanHostKey(String(t.config.host), Number(t.config.port ?? 22)); hostKey = k.line; fingerprint = k.fingerprint; }
const r = await buildRemote(t, rclone, hostKey);
try {
const flags = ['--timeout', '30s', '--contimeout', '15s', '--retries', '1', '--low-level-retries', '1'];
await exec(rclone, ['mkdir', r.remote, ...flags], { env: r.env, timeoutMs: 60_000 });
const f = `${r.remote.replace(/\/+$/, '')}/.kc-schreibtest`;
await exec(rclone, ['rcat', f, ...flags], { env: r.env, input: 'ok', timeoutMs: 60_000 });
await exec(rclone, ['deletefile', f, ...flags], { env: r.env, timeoutMs: 60_000 });
} finally { await r.cleanup(); }
return { ok: true, hostKey, fingerprint };
} catch (e) { return { ok: false, error: friendly(e) }; }
}

43
apps/api/src/server.ts Normal file
View file

@ -0,0 +1,43 @@
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import { ZodError } from 'zod';
import { config } from './core/config.js';
import { registerAuth } from './core/auth.js';
import { registerPermissions } from './core/policy.js';
import { AppError } from './core/errors.js';
import { InvalidTransition } from '@kc/platform/statemachine';
import { modules } from './modules/index.js';
export async function buildApp() {
const app = Fastify({
trustProxy: true,
bodyLimit: 1024 * 1024,
logger: { level: config.isProd ? 'info' : 'debug', redact: ['req.headers.cookie', 'req.headers.authorization', 'req.body.password', 'req.body.current', 'req.body.next'] },
genReqId: () => crypto.randomUUID(),
});
await app.register(helmet, { contentSecurityPolicy: false }); // CSP setzt das Web-Frontend
await app.register(cookie);
await app.register(rateLimit, { global: true, max: 300, timeWindow: '1 minute' });
registerAuth(app);
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) return reply.code(err.status).send({ error: { code: err.code, message: err.message }, correlationId: req.correlationId });
if (err instanceof InvalidTransition) return reply.code(409).send({ error: { code: 'INVALID_TRANSITION', message: 'Diese Aktion ist im aktuellen Zustand nicht möglich. Bitte Ansicht neu laden.' }, correlationId: req.correlationId });
if (err instanceof ZodError) return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Eingabe ungültig', details: err.issues.map((i) => ({ path: i.path.join('.'), message: i.message })) }, correlationId: req.correlationId });
const status = (err as { statusCode?: number }).statusCode;
if (status && status < 500) return reply.code(status).send({ error: { code: 'REQUEST_ERROR', message: (err as Error).message }, correlationId: req.correlationId });
req.log.error({ err, correlationId: req.correlationId }, 'unhandled');
return reply.code(500).send({ error: { code: 'INTERNAL', message: 'Interner Fehler' }, correlationId: req.correlationId });
});
for (const m of modules) registerPermissions(m.permissions ?? {});
for (const m of modules) await app.register(async (scope) => { await m.register(scope); }, { prefix: '/v1' });
return app;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const app = await buildApp();
await app.listen({ port: config.port, host: '127.0.0.1' });
}

View file

@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../src/server.js';
import { query } from '../src/core/db.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkapi-')); const statusFile = join(root, 'status.json'); const reqDir = join(root, 'requests');
beforeAll(async () => { process.env.BACKUP_STATUS_FILE = statusFile; process.env.BACKUP_REQUEST_DIR = reqDir; process.env.BACKUP_REMOTES = 'nas:kundencenter'; app = await buildApp(); await app.ready(); });
afterAll(() => { rmSync(root, { recursive: true, force: true }); delete process.env.BACKUP_STATUS_FILE; delete process.env.BACKUP_REQUEST_DIR; delete process.env.BACKUP_REMOTES; });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
describe('Backup-Seite (API)', () => {
it('zeigt Zustand nur Berechtigten, ohne Statusdatei als "noch nie gelaufen"', async () => {
const admin = await staff('bk-adm@x.test', 'admin'); const sup = await staff('bk-sup@x.test', 'support');
expect((await call(app, sup, 'GET', '/admin/backup')).statusCode).toBe(403);
expect((await app.inject({ method: 'GET', url: '/v1/admin/backup' })).statusCode).toBe(401);
const s0 = (await call(app, admin, 'GET', '/admin/backup')).json();
expect(s0.lastRun).toBeNull(); expect(s0.stale).toBe(true); expect(s0.hasExternalTarget).toBe(true); expect(s0.remotes).toEqual(['nas:kundencenter']); expect(s0.retention).toEqual({ daily: 14, weekly: 8, monthly: 12 });
mkdirSync(join(root), { recursive: true });
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date().toISOString(), ok: true, file: 'x.age', sizeBytes: 1000, durationMs: 900, targets: [{ name: 'lokal', ok: true }] }, lastRestoreTest: { at: new Date().toISOString(), ok: true, checks: { auditKette: true } }, history: [{ at: new Date().toISOString(), ok: true }] }));
const s1 = (await call(app, admin, 'GET', '/admin/backup')).json(); expect(s1.stale).toBe(false); expect(s1.restoreStale).toBe(false); expect(s1.history).toHaveLength(1);
// veraltet, wenn der letzte Erfolg zu alt ist
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date(Date.now() - 30 * 3600000).toISOString(), ok: true, targets: [] } }));
expect((await call(app, admin, 'GET', '/admin/backup')).json().stale).toBe(true);
});
it('legt Anfragen nur für feste Aktionen ab, nur einmal gleichzeitig, mit Audit', async () => {
const admin = await staff('bk-adm2@x.test', 'admin'); const sup = await staff('bk-sup2@x.test', 'support');
expect((await call(app, sup, 'POST', '/admin/backup/run', { action: 'backup' })).statusCode).toBe(403);
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'rm -rf /' })).statusCode).toBe(400); // nur feste Aktionen
expect(existsSync(reqDir) ? readdirSyncSafe(reqDir) : []).toEqual([]);
const ok = await call(app, admin, 'POST', '/admin/backup/run', { action: 'backup' }); expect(ok.statusCode).toBe(202);
expect(readdirSyncSafe(reqDir)).toEqual(['backup-run']);
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_PENDING'); // eine Anfrage nach der anderen
expect((await call(app, admin, 'GET', '/admin/backup')).json().pendingRequests).toEqual(['backup']);
unlinkSync(join(reqDir, 'backup-run'));
writeFileSync(statusFile, JSON.stringify({ running: { action: 'backup', since: new Date().toISOString() } }));
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_RUNNING');
writeFileSync(statusFile, JSON.stringify({}));
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).statusCode).toBe(202);
expect(readdirSyncSafe(reqDir)).toEqual(['backup-restore-test']);
expect((await query("SELECT action FROM audit_events WHERE action LIKE 'backup.request.%' ORDER BY id")).map((r) => r.action)).toEqual(['backup.request.backup', 'backup.request.restore-test']);
});
});
import { readdirSync } from 'node:fs';
function readdirSyncSafe(d: string): string[] { try { return readdirSync(d); } catch { return []; } }

View file

@ -0,0 +1,124 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../src/server.js';
import { one, query } from '../src/core/db.js';
import { parseName, fileName } from '../src/ops/retention.js';
import { passwordProblem, setBackupPassword } from '../src/ops/settings.js';
import { buildRemote, friendly, type TargetRow } from '../src/ops/targets.js';
import { loadBackupConfig, runBackup, runRestoreTest } from '../src/ops/backup.js';
import { config } from '../src/core/config.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkset-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const dest = join(root, 'ziel'); const status = join(root, 'status.json');
beforeAll(async () => { delete process.env.BACKUP_AGE_RECIPIENT; delete process.env.BACKUP_REMOTES; app = await buildApp(); await app.ready(); mkdirSync(envDir); mkdirSync(dest); writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); });
afterAll(() => rmSync(root, { recursive: true, force: true }));
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
describe('Backup-Passwort', () => {
it('verlangt mehr als 10 Zeichen', () => {
expect(passwordProblem('1234567890')).toMatch(/länger als 10/); // genau 10: zu kurz
expect(passwordProblem('12345678901')).toBeNull(); // 11: ok
expect(passwordProblem('aaaaaaaaaaaa')).toMatch(/wiederholt/);
expect(passwordProblem('abc\ndefghijkl')).toMatch(/Zeilenumbr/);
expect(passwordProblem('x'.repeat(201))).toMatch(/höchstens/);
});
it('setzt es nur mit Superadmin, Wiederholung und eigenem Anmeldepasswort; speichert es verschlüsselt', async () => {
const admin = await staff('bs-adm@x.test', 'admin'); const sa = await staff('bs-sa@x.test', 'superadmin');
const body = { password: 'Sehr-langes-Backup-Passwort-1', repeat: 'Sehr-langes-Backup-Passwort-1', currentPassword: 'correct-horse-battery' };
expect((await call(app, admin, 'PUT', '/admin/backup/password', body)).statusCode).toBe(403); // Admin darf nicht
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, currentPassword: 'falsch-falsch-falsch' })).json().error.code).toBe('INVALID_CREDENTIALS');
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, repeat: 'anderes-Passwort-12' })).json().error.code).toBe('PASSWORD_MISMATCH');
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: '1234567890', repeat: '1234567890' })).json().error.code).toBe('WEAK_PASSWORD');
const s0 = (await call(app, sa, 'GET', '/admin/backup/settings')).json(); expect(s0.encryption).toMatchObject({ mode: 'none', passwordSet: false, minLength: 11 });
expect((await call(app, sa, 'PUT', '/admin/backup/password', body)).json().version).toBe(1);
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: 'Noch-ein-anderes-Passwort-2', repeat: 'Noch-ein-anderes-Passwort-2' })).json().version).toBe(2);
const s1 = (await call(app, admin, 'GET', '/admin/backup/settings')).json(); expect(s1.encryption).toMatchObject({ mode: 'password', passwordSet: true, version: 2 });
expect(JSON.stringify(s1)).not.toContain('Passwort-2');
const row = await one("SELECT value_enc FROM backup_settings WHERE `key` = 'password'"); expect(row!.value_enc).toMatch(/^v1:/); expect(row!.value_enc).not.toContain('Passwort');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toMatch(/Backup-Passwort-1|Passwort-2/); // nie im Audit
expect((await query("SELECT result FROM audit_events WHERE action = 'backup.password.change' ORDER BY id")).map((r) => r.result)).toEqual(['denied', 'success', 'success']);
});
});
describe('Externe Ziele', () => {
it('legt Ziele an, prüft Eingaben, liefert nie Geheimnisse und speichert sie verschlüsselt', async () => {
const sa = await staff('bt-sa@x.test', 'superadmin'); const admin = await staff('bt-adm@x.test', 'admin');
const ftp = { type: 'ftp', name: 'NAS-FTP', host: 'nas.example.test', user: 'backup', password: 'geheimes-ftp-passwort', tls: 'explicit', path: 'kundencenter' };
expect((await call(app, admin, 'POST', '/admin/backup/targets', ftp)).statusCode).toBe(403);
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: 'localhost' })).json().error.code).toBe('BAD_HOST'); // keine Zugriffe auf den Server selbst
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: '169.254.169.254' })).json().error.code).toBe('BAD_HOST');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, path: '../../etc' })).json().error.code).toBe('BAD_PATH');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Etc', path: '/etc/cron.d' })).json().error.code).toBe('BAD_PATH');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'sftp', name: 'S', host: 'sftp.example.test', user: 'u', authType: 'key', privateKey: 'kein schlüssel' })).json().error.code).toBe('SECRET_MISSING');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'gdrive', name: 'G', token: 'kein-json-token-abc' })).json().error.code).toBe('BAD_TOKEN');
const ok = await call(app, sa, 'POST', '/admin/backup/targets', ftp); expect(ok.statusCode).toBe(200);
expect((await call(app, sa, 'POST', '/admin/backup/targets', ftp)).json().error.code).toBe('NAME_EXISTS');
const list = (await call(app, admin, 'GET', '/admin/backup/settings')).json().targets; // Admin sieht Ziele, aber keine Geheimnisse
expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ name: 'NAS-FTP', type: 'ftp', hasSecret: true, enabled: true });
expect(JSON.stringify(list)).not.toContain('geheimes-ftp'); expect(JSON.stringify(list)).not.toContain('secrets_enc');
const raw = await one('SELECT secrets_enc FROM backup_targets'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('geheimes');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('geheimes-ftp');
// aktivieren/deaktivieren, löschen
const id = list[0].id; expect((await call(app, sa, 'PATCH', `/admin/backup/targets/${id}`, { enabled: false })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/backup')).json().hasExternalTarget).toBe(false); // deaktiviert zählt nicht
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(200);
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(404);
});
it('testet Ziele mit echtem Schreibtest und meldet Fehler verständlich', async () => {
const sa = await staff('bt2-sa@x.test', 'superadmin');
const good = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Laufwerk', path: dest })).json().id;
const t1 = (await call(app, sa, 'POST', `/admin/backup/targets/${good}/test`)).json(); expect(t1.ok).toBe(true); expect(readdirSync(dest)).toEqual([]); // Testdatei wurde wieder gelöscht
const bad = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Fehlt', path: join(root, 'gibt-es-nicht') })).json().id;
const t2 = (await call(app, sa, 'POST', `/admin/backup/targets/${bad}/test`)).json(); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/existiert nicht/);
const s = (await call(app, sa, 'GET', '/admin/backup/settings')).json().targets; expect(s.find((x: any) => x.name === 'Laufwerk').lastTestOk).toBe(true); expect(s.find((x: any) => x.name === 'Fehlt').lastTestOk).toBe(false);
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'backup.target.test'"))[0]!.n).toBe(2);
await call(app, sa, 'DELETE', `/admin/backup/targets/${bad}`);
});
it('baut rclone-Umgebung ohne Passwörter im Klartext und mit festem Server-Schlüssel', async () => {
const t: TargetRow = { id: '12345678-aaaa-bbbb-cccc-1234567890ab', name: 'x', type: 'sftp', path: 'kc', enabled: true, hostKey: 'sftp.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc', config: { host: 'sftp.example.test', port: 2222, user: 'bk', authType: 'password' }, secrets: { password: 'klartext-passwort-9' } };
const r = await buildRemote(t);
const P = 'RCLONE_CONFIG_KCT12345678_'; expect(r.remote).toBe('KCT12345678:kc'); expect(r.env[`${P}TYPE`]).toBe('sftp'); expect(r.env[`${P}PORT`]).toBe('2222');
expect(r.env[`${P}PASS`]).toBeTruthy(); expect(r.env[`${P}PASS`]).not.toContain('klartext'); // obscured
const kh = r.env[`${P}KNOWN_HOSTS_FILE`]!; expect(readFileSync(kh, 'utf8')).toContain('ssh-ed25519'); expect(r.env.RCLONE_CONFIG).toBeTruthy(); await r.cleanup(); expect(existsSync(kh)).toBe(false); expect(existsSync(r.env.RCLONE_CONFIG!)).toBe(false);
expect(friendly(new Error('ssh: handshake failed: knownhosts: key mismatch'))).toMatch(/Server-Schlüssel/);
expect(friendly(new Error('Permission denied (publickey,password)'))).toMatch(/Anmeldung/);
expect(friendly(new Error('dial tcp: lookup nas.x: no such host'))).toMatch(/nicht gefunden/);
});
});
describe('Backup mit Passwort und Ziel aus der Oberfläche', () => {
it('verschlüsselt mit gpg, lädt zum definierten Ziel, Restore-Test besteht; falsches/geändertes Passwort wird erkannt', async () => {
const sa = await staff('bp-sa@x.test', 'superadmin');
await query('DELETE FROM backup_targets'); // Ziele früherer Tests entfernen
await setBackupPassword('Mein-Backup-Passwort-2026', null);
await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Ziel-Laufwerk', path: dest });
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
expect(r.file).toMatch(/\.tar\.gz\.gpg$/); expect(parseName(r.file!)?.ext).toBe('gpg'); expect(fileName(new Date(), 'gpg')).toMatch(/\.gpg$/);
expect(r.targets.map((t) => [t.name.startsWith('lokal') ? 'lokal' : t.name, t.ok])).toEqual([['lokal', true], ['Ziel-Laufwerk', true]]);
expect(readdirSync(dest).sort()).toEqual([r.file, `${r.file}.sha256`].sort());
// Ohne Passwort unlesbar, mit Standard-gpg lesbar (Notfall-Weg ohne unser Tool)
const file = join(dest, r.file!); const home = mkdtempSync(join(tmpdir(), 'gh-'));
const dec = (pw: string) => execFileSync('gpg', ['--homedir', home, '--batch', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', file], { input: pw, stdio: ['pipe', 'pipe', 'pipe'] });
expect(() => dec('falsches-Passwort-123')).toThrow(); expect(dec('Mein-Backup-Passwort-2026').length).toBeGreaterThan(500);
const raw = readFileSync(file); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false);
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true); expect(t.checks).toMatchObject({ auditKette: true, geheimnisseOk: true });
// Passwort ändern: alte Sicherung ist mit dem neuen Passwort nicht mehr lesbar (klare Meldung), neue Sicherung besteht
await setBackupPassword('Ganz-Neues-Passwort-2027', null);
const old = await runRestoreTest(cfg(), r.file); expect(old.ok).toBe(false); expect(old.error).toMatch(/Passwort passt nicht/);
const r2 = await runBackup(cfg(), new Date(Date.now() + 120000)); expect(r2.ok).toBe(true);
expect((await runRestoreTest(cfg(), r2.file)).ok).toBe(true);
});
it('sichert nie unverschlüsselt: ohne Passwort und Schlüssel bricht der Lauf ab', async () => {
await query("DELETE FROM backup_settings");
const r = await runBackup(cfg(), new Date(Date.now() + 240000)); expect(r.ok).toBe(false); expect(r.error).toMatch(/Keine Verschlüsselung/);
});
});

View file

@ -0,0 +1,87 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { statSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { encrypt } from '../src/core/crypto.js';
import { audit } from '../src/core/audit.js';
import { run as dbRun } from '../src/core/db.js';
import { config } from '../src/core/config.js';
import { labelFor, parseName, selectDeletions, fileName } from '../src/ops/retention.js';
import { loadBackupConfig, runBackup, runRestoreTest, readStatus, latestLocal } from '../src/ops/backup.js';
const d = (s: string) => new Date(`${s}T03:00:00Z`);
describe('Aufbewahrung', () => {
it('benennt und erkennt Sicherungen', () => {
expect(fileName(new Date('2026-09-26T02:30:05Z'))).toBe('kundencenter-20260926-023005-daily.tar.gz.age');
expect(labelFor(d('2026-09-27'))).toBe('weekly'); // Sonntag
expect(labelFor(d('2026-10-01'))).toBe('monthly'); // Monatserster hat Vorrang vor Wochentag
expect(labelFor(d('2026-09-30'))).toBe('daily');
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age')?.label).toBe('daily');
expect(parseName('fremd.txt')).toBeNull();
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age.sha256')).toBeNull();
});
it('löscht nach Alter je Kategorie, nie Fremdes, nie unter den Mindestbestand', () => {
const mk = (s: string) => fileName(d(s)); const now = d('2026-09-30'); const keep = { daily: 14, weekly: 8, monthly: 12 };
const names = [mk('2026-09-29'), mk('2026-09-10'), mk('2026-08-02'), mk('2026-08-01'), mk('2025-08-01'), mk('2025-10-01'), 'notizen.txt'];
const del = selectDeletions(names, now, keep);
expect(del).toContain(mk('2026-09-10')); // daily, 20 Tage
expect(del).toContain(mk('2025-08-01')); // monthly, älter als 12 Monate
expect(del).not.toContain(mk('2026-09-29')); expect(del).not.toContain(mk('2026-08-01')); expect(del).not.toContain(mk('2025-10-01')); expect(del).not.toContain('notizen.txt');
// Mindestbestand: auch wenn alles alt ist, bleiben die 3 neuesten
const old = ['2024-01-05', '2024-01-06', '2024-01-08', '2024-01-09'].map(mk);
expect(selectDeletions(old, now, keep)).toEqual([old[0]]);
expect(selectDeletions([], now, keep)).toEqual([]);
});
});
describe('Backup und Wiederherstellungstest (echte Werkzeuge, Testdatenbank)', () => {
const root = mkdtempSync(join(tmpdir(), 'kc-bk-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const remote = join(root, 'remote'); const idFile = join(root, 'age-key.txt'); const status = join(root, 'state', 'status.json');
let recipient = '';
beforeAll(async () => {
mkdirSync(envDir); mkdirSync(remote);
writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); writeFileSync(join(envDir, 'db.env'), 'DB_NAME=x\n');
execFileSync('age-keygen', ['-o', idFile], { stdio: 'pipe' }); recipient = /public key: (age1\w+)/.exec(readFileSync(idFile, 'utf8'))![1]!;
// echte Beispieldaten inkl. verschlüsselter Geheimnisse und Audit-Kette
await dbRun("INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc) VALUES (UUID(), 'mock', 'bk-test', '{}', ?)", [encrypt(JSON.stringify({ token: 'geheim' }))]);
await audit({ actorType: 'system', action: 'test.eins' }); await audit({ actorType: 'system', action: 'test.zwei' });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: idFile, BACKUP_REMOTES: remote, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
it('erstellt ein verschlüsseltes Backup, lädt es zum Ziel und der Wiederherstellungstest besteht', async () => {
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
const local = readdirSync(dir).filter((n) => parseName(n)); expect(local).toHaveLength(1);
expect(statSync(join(dir, local[0]!)).mode & 0o077).toBe(0); // keine Rechte für Gruppe/Andere
expect(existsSync(join(dir, `${local[0]}.sha256`))).toBe(true); expect(readdirSync(remote).sort()).toEqual([local[0], `${local[0]}.sha256`].sort());
// Klartext darf nirgends liegen: weder SQL noch Schlüssel in der Datei
const raw = readFileSync(join(dir, local[0]!)); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false); expect(raw.includes(Buffer.from(config.secretKey.toString('base64')))).toBe(false);
expect(raw.subarray(0, 21).toString()).toContain('age-encryption.org');
expect(readdirSync(dir).some((n) => n.startsWith('.work-'))).toBe(false); // Arbeitsordner aufgeräumt
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true);
expect(t.checks).toMatchObject({ pruefsumme: true, dumpPruefsumme: true, zeilenzahlen: true, migrationen: true, auditKette: true, geheimnisseOk: true }); expect(Number(t.checks.geheimnisseGeprueft)).toBeGreaterThanOrEqual(1);
const s = await readStatus(cfg()); expect(s.lastRun?.ok).toBe(true); expect(s.lastRestoreTest?.ok).toBe(true);
expect(await latestLocal(cfg())).toBe(local[0]);
});
it('meldet einen defekten Ziel-Ablauf als Fehler, behält aber das lokale Backup', async () => {
const bad = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_REMOTES: '/proc/nicht/schreibbar', BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
const r = await runBackup(bad, new Date(Date.now() + 60000)); expect(r.ok).toBe(false);
expect(r.targets.find((x) => x.name.startsWith('/proc'))?.ok).toBe(false); expect(r.targets[0]!.ok).toBe(true);
expect((await readStatus(cfg())).lastRun?.ok).toBe(false);
// ohne Passwort und ohne age-Schlüssel wird nichts unverschlüsselt gesichert
const none = await runBackup(loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }), new Date(Date.now() + 120000));
expect(none.ok).toBe(false); expect(none.error).toMatch(/Keine Verschlüsselung/);
});
it('erkennt manipulierte Sicherungen und fehlende Schlüssel', async () => {
const name = (await latestLocal(cfg()))!; const file = join(dir, name);
const before = readFileSync(file); appendFileSync(file, 'x');
const t1 = await runRestoreTest(cfg(), name); expect(t1.ok).toBe(false); expect(t1.error).toMatch(/Prüfsumme/);
writeFileSync(file, before);
const other = join(root, 'other.txt'); execFileSync('age-keygen', ['-o', other], { stdio: 'pipe' });
const wrong = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: other, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
const t2 = await runRestoreTest(wrong, name); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/age/);
const none = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
expect((await runRestoreTest(none, name)).error).toMatch(/Schlüssel/);
expect((await runRestoreTest(cfg(), name)).ok).toBe(true); // Original wieder heil -> Test besteht
});
});

View file

@ -0,0 +1,120 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { syncInstance } from '@kc/connectors';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const drain = async () => { for (let i = 0; i < 20 && (await runOnce(() => undefined)); i++); };
describe('Connectoren, Ressourcen und Aufträge', () => {
it('Ende-zu-Ende: Verbindung, Abgleich, Zuweisung, Rechte, idempotenter Auftrag, Ausfall mit letztem Stand', async () => {
resetMock();
const admin = await staff('adm@conn.test', 'admin'); const sup = await staff('sa@conn.test', 'superadmin');
const A = await customer(admin, 'Firma A', 'a@conn.test'); const B = await customer(admin, 'Firma B', 'b@conn.test');
// Nur Superadmin darf Verbindungen (mit Geheimnissen) anlegen
const body = { connector: 'mock', name: 'Mock-Test', values: { instance: 'e2e' } };
expect((await call(app, admin, 'POST', '/admin/connectors', body)).statusCode).toBe(403);
const created = (await call(app, sup, 'POST', '/admin/connectors', body)).json();
const list = (await call(app, sup, 'GET', '/admin/connectors')).json();
expect(list[0].hasSecrets).toBe(false); expect(JSON.stringify(list)).not.toMatch(/secrets_enc|password/);
// Abgleich über Worker-Job (angelegt beim Erstellen)
await drain();
const inst = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(inst.health).toBe('ok'); expect(inst.resources).toBe(3);
const all = (await call(app, admin, 'GET', '/resources')).json();
expect(all).toHaveLength(3);
// Nicht zugewiesene Ressourcen sind für Kunden unsichtbar
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(0);
const res = all.find((r: any) => r.name.includes('Pro'));
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
// Zuweisung an A: A sieht sie, B nicht
expect((await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org })).statusCode).toBe(200);
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(1);
expect((await call(app, B.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).statusCode).toBe(404);
// Aktionen: standardmäßig keine für Kunden (Ressourcenregel), Staff sieht alle unterstützten
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual([]);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).json().error.code).toBe('ACTION_NOT_ALLOWED');
expect((await call(app, admin, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend', 'extend']);
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { customerActions: ['suspend', 'unsuspend'] });
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend']);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'extend', days: 30 })).statusCode).toBe(403);
// Idempotenz: gleicher Schlüssel => ein Auftrag
const hdr = { cookie: A.client.cookie, 'x-csrf-token': A.client.csrf, 'idempotency-key': 'klick-0001-abcdef' };
const r1 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
const r2 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
expect(r1.statusCode).toBe(202); expect(r2.json().jobId).toBe(r1.json().jobId); expect(r2.json().duplicate).toBe(true);
expect((await query("SELECT id FROM jobs WHERE type='connector.execute'")).length).toBe(1);
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('scheduled');
expect((await call(app, B.client, 'GET', `/jobs/${r1.json().jobId}`)).statusCode).toBe(404);
// Worker führt aus; Zustand + Audit
await drain();
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('succeeded');
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().state).toBe('suspended');
const au = await one("SELECT actor_id, connector, result FROM audit_events WHERE action='resource.suspend' ORDER BY id DESC LIMIT 1");
expect(au!.connector).toBe('mock'); expect(au!.result).toBe('success');
// Providerausfall: letzter Stand bleibt sichtbar, mit Hinweis
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'true' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
const down = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(down.health).toBe('down'); expect(down.lastOkAt).toBeTruthy();
const stale = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
expect(stale.stale).toBe(true); expect(stale.state).toBe('suspended'); expect(stale.staleReason).toMatch(/nicht erreichbar/);
expect(stale.allowedActions).toEqual([]); // keine Aktionen während des Ausfalls
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.down'"))!.n).toBe(1);
// Aktion bei Ausfall: wird wiederholt (retrying), nicht verloren – nach Erholung erfolgreich
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'false' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
expect((await call(app, sup, 'GET', '/admin/connectors')).json()[0].health).toBe('ok');
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.recovered'"))!.n).toBe(1);
});
it('wiederholt fehlgeschlagene Aufträge begrenzt, destruktive nie', async () => {
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]);
const res = await one('SELECT id FROM resources LIMIT 1');
const mk = async (payload: object, max = 2) => { const id = crypto.randomUUID(); await run('INSERT INTO jobs (id,type,payload,max_attempts) VALUES (?,?,?,?)', [id, 'connector.execute', JSON.stringify({ resourceId: res!.id, actorUserId: null, ...payload }), max]); return id; };
const retry = await mk({ action: 'suspend' });
await run("UPDATE jobs SET status='succeeded' WHERE type='connector.execute' AND id <> ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status, attempts, last_error FROM jobs WHERE id = ?', [retry]))).toMatchObject({ status: 'retrying', attempts: 1 });
await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE id = ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status FROM jobs WHERE id = ?', [retry]))!.status).toBe('needs_review'); // Limit erreicht -> manuelle Prüfung
const term = await mk({ action: 'terminate', destructive: true }, 5);
await runOnce(() => undefined);
const t = await one('SELECT status, attempts FROM jobs WHERE id = ?', [term]);
expect(t).toMatchObject({ status: 'needs_review', attempts: 1 }); // nicht automatisch wiederholt
// Manuelle Wiederholung: normaler Auftrag ja, destruktiver nein
const adm = await staff('adm2@conn.test', 'admin');
expect((await call(app, adm, 'POST', `/admin/jobs/${retry}/retry`)).statusCode).toBe(200);
expect((await call(app, adm, 'POST', `/admin/jobs/${term}/retry`)).json().error.code).toBe('DESTRUCTIVE');
});
});

218
apps/api/test/core.test.ts Normal file
View file

@ -0,0 +1,218 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { verifyAuditChain } from '../src/core/audit.js';
import { call, code, login, makeUser, nextCode } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
async function staffWithMfa(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
const conf = await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) });
expect(conf.statusCode).toBe(200);
expect(conf.json().recoveryCodes).toHaveLength(10);
return { client, secret: setup.secret as string };
}
describe('Login & Sitzungen', () => {
it('lehnt falsches Passwort generisch ab und protokolliert', async () => {
await makeUser({ email: 'a@example.test' });
const r = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'a@example.test', password: 'falsch-falsch-falsch' } });
expect(r.statusCode).toBe(401);
const r2 = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'unbekannt@example.test', password: 'falsch-falsch-falsch' } });
expect(r2.json().error.code).toBe(r.json().error.code);
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='auth.login' AND result='denied'"))!.n).toBeGreaterThanOrEqual(2);
});
it('sperrt das Konto nach 5 Fehlversuchen', async () => {
await makeUser({ email: 'lock@example.test' });
for (let i = 0; i < 5; i++) await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'falsch-falsch-falsch' } });
const ok = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'correct-horse-battery' } });
expect(ok.statusCode).toBe(401);
});
it('verlangt CSRF-Token bei schreibenden Requests', async () => {
await makeUser({ email: 'csrf@example.test' });
const { client } = await login(app, 'csrf@example.test');
const r = await app.inject({ method: 'POST', url: '/v1/auth/logout', headers: { cookie: client.cookie } });
expect(r.statusCode).toBe(403);
expect(r.json().error.code).toBe('BAD_CSRF');
});
it('kann Sitzungen widerrufen', async () => {
await makeUser({ email: 's@example.test' });
const a = (await login(app, 's@example.test')).client;
const b = (await login(app, 's@example.test')).client;
const list = (await call(app, a, 'GET', '/auth/sessions')).json();
expect(list).toHaveLength(2);
const other = list.find((s: any) => !s.current);
expect((await call(app, a, 'DELETE', `/auth/sessions/${other.id}`)).statusCode).toBe(200);
expect((await call(app, b, 'GET', '/auth/me')).statusCode).toBe(401);
});
});
describe('2FA', () => {
it('erzwingt 2FA-Einrichtung für Staff und TOTP beim Login', async () => {
await makeUser({ email: 'staff1@example.test', kind: 'staff', staffRole: 'admin' });
const { client } = await login(app, 'staff1@example.test');
expect((await call(app, client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_ENROLL_REQUIRED');
const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
expect((await call(app, client, 'POST', '/auth/mfa/confirm', { code: '000000' })).statusCode).toBe(400);
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) });
expect((await call(app, client, 'GET', '/admin/customers')).statusCode).toBe(200);
// neuer Login: Passwort allein reicht nicht
const l2 = await login(app, 'staff1@example.test');
expect(l2.res.json().status).toBe('mfa_required');
expect((await call(app, l2.client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_REQUIRED');
expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: '123456' })).statusCode).toBe(401);
expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: nextCode(setup.secret) })).statusCode).toBe(200);
expect((await call(app, l2.client, 'GET', '/admin/customers')).statusCode).toBe(200);
});
it('speichert das TOTP-Secret nur verschlüsselt', async () => {
const r = await one('SELECT secret_enc FROM mfa_totp LIMIT 1');
expect(r!.secret_enc).toMatch(/^v1:/);
});
});
describe('Kundenanlage & Mandantentrennung', () => {
it('legt Kunden an, lädt Owner ein, und trennt Mandanten strikt', async () => {
const { client: admin } = await staffWithMfa('admin@example.test', 'admin');
const mk = async (name: string, mail: string) => (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name }, billing: { city: 'Berlin' } })).json();
const A = await mk('Firma A', 'owner-a@example.test');
const B = await mk('Firma B', 'owner-b@example.test');
expect(A.customerNumber).toMatch(/^K-\d+$/);
expect(A.customerNumber).not.toBe(B.customerNumber);
expect(A.inviteLink).toContain('/einladung?token=');
// Einladung annehmen
for (const [inv, pw] of [[A.inviteLink, 'passwort-owner-a1'], [B.inviteLink, 'passwort-owner-b1']] as const) {
const token = new URL(inv).searchParams.get('token');
expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: pw, repeat: pw } })).statusCode).toBe(200);
}
const ownerA = (await login(app, 'owner-a@example.test', 'passwort-owner-a1')).client;
expect((await call(app, ownerA, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200);
expect((await call(app, ownerA, 'GET', `/orgs/${B.id}`)).statusCode).toBe(404); // fremde Org
expect((await call(app, ownerA, 'GET', `/orgs/${B.id}/members`)).statusCode).toBe(404);
expect((await call(app, ownerA, 'POST', `/orgs/${B.id}/invitations`, { email: 'x@example.test', name: 'X', role: 'member' })).statusCode).toBe(404);
expect((await call(app, ownerA, 'GET', '/admin/customers')).statusCode).toBe(403); // keine Staff-Rechte
expect((await call(app, ownerA, 'GET', '/admin/users')).statusCode).toBe(403);
// Einladung nur einmal nutzbar
const token = new URL(A.inviteLink).searchParams.get('token');
expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: 'anderes-passwort-12', repeat: 'anderes-passwort-12' } })).statusCode).toBe(400);
// Mitglied einladen; Member darf nicht einladen
const inv = (await call(app, ownerA, 'POST', `/orgs/${A.id}/invitations`, { email: 'm@example.test', name: 'M', role: 'member' })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } });
const member = (await login(app, 'm@example.test', 'member-passwort-1')).client;
expect((await call(app, member, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200);
expect((await call(app, member, 'POST', `/orgs/${A.id}/invitations`, { email: 'y@example.test', name: 'Y', role: 'member' })).statusCode).toBe(403);
// Discord-Job wurde idempotent eingereiht, ohne personenbezogene Daten
const jobs = await query("SELECT payload FROM jobs WHERE type='discord.notify' AND idempotency_key IN (?, ?)", [`customer.created:${A.id}`, `customer.created:${B.id}`]);
expect(jobs).toHaveLength(2);
expect(JSON.stringify(jobs)).not.toContain('example.test');
});
it('verhindert doppelte E-Mail bei Kundenanlage', async () => {
const { client: admin } = await staffWithMfa('admin2@example.test', 'admin');
const r = await call(app, admin, 'POST', '/admin/customers', { type: 'business', name: 'Dup', owner: { email: 'owner-a@example.test', name: 'Dup' } });
expect(r.statusCode).toBe(409);
});
});
describe('Rechte', () => {
it('support darf lesen, aber nicht Kunden anlegen; admin darf keine Admins anlegen', async () => {
const { client: sup } = await staffWithMfa('support@example.test', 'support');
expect((await call(app, sup, 'GET', '/admin/customers')).statusCode).toBe(200);
expect((await call(app, sup, 'POST', '/admin/customers', { type: 'business', name: 'N', owner: { email: 'n@example.test', name: 'N' } })).statusCode).toBe(403);
expect((await call(app, sup, 'GET', '/admin/audit')).statusCode).toBe(403);
const { client: adm } = await staffWithMfa('admin3@example.test', 'admin');
expect((await call(app, adm, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).json().error.code).toBe('PRIVILEGED_ONLY');
expect((await call(app, adm, 'POST', '/admin/users', { email: 'newsup@example.test', name: 'N', staffRole: 'support' })).statusCode).toBe(200);
const { client: sa } = await staffWithMfa('super@example.test', 'superadmin');
expect((await call(app, sa, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).statusCode).toBe(200);
});
});
describe('Audit', () => {
it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => {
expect((await verifyAuditChain()).brokenAt).toBeNull();
const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events'));
expect(dump).not.toMatch(/passwort-owner|correct-horse/);
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
});
});
describe('Passwort-Wiederholung und 2FA zurücksetzen', () => {
it('verlangt die Wiederholung des neuen Passworts', async () => {
await makeUser({ email: 'pw@example.test' });
const { client } = await login(app, 'pw@example.test');
const bad = await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-124' });
expect(bad.statusCode).toBe(400); expect(bad.json().error.code).toBe('PASSWORD_MISMATCH');
expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123' })).statusCode).toBe(400); // repeat fehlt
expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-123' })).statusCode).toBe(200);
expect((await login(app, 'pw@example.test', 'neues-passwort-123')).res.statusCode).toBe(200);
});
it('erlaubt Kunden, 2FA zu entfernen und mit neuem Gerät neu einzurichten', async () => {
await makeUser({ email: 'phone@example.test' });
const { client } = await login(app, 'phone@example.test');
const s1 = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s1.secret) });
expect((await call(app, client, 'POST', '/auth/mfa/setup')).json().error.code).toBe('MFA_ALREADY_ENABLED');
// ohne korrektes Passwort / Code nicht möglich
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'falsch-falsch-falsch', code: nextCode(s1.secret) })).statusCode).toBe(403);
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: '000000' })).statusCode).toBe(403);
expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: nextCode(s1.secret) })).statusCode).toBe(200);
expect((await one('SELECT COUNT(*) n FROM mfa_totp m JOIN users u ON u.id = m.user_id WHERE u.email = ?', ['phone@example.test']))!.n).toBe(0);
// Login ohne 2FA, danach neu einrichten mit neuem Secret
const l2 = await login(app, 'phone@example.test'); expect(l2.res.json().status).toBe('ok');
const s2 = (await call(app, l2.client, 'POST', '/auth/mfa/setup')).json();
expect(s2.secret).not.toBe(s1.secret);
expect((await call(app, l2.client, 'POST', '/auth/mfa/confirm', { code: code(s2.secret) })).statusCode).toBe(200);
expect((await login(app, 'phone@example.test')).res.json().status).toBe('mfa_required');
});
it('erlaubt Support-Reset der 2FA für Kunden, für Mitarbeiter nur Superadmin', async () => {
const { client: adm } = await staffWithMfa('resetadm@example.test', 'admin');
const cust = await makeUser({ email: 'lost@example.test' });
const cl = (await login(app, 'lost@example.test')).client;
const st = (await call(app, cl, 'POST', '/auth/mfa/setup')).json(); await call(app, cl, 'POST', '/auth/mfa/confirm', { code: code(st.secret) });
expect((await call(app, adm, 'POST', `/admin/users/${cust}/mfa-reset`)).statusCode).toBe(200);
expect((await call(app, cl, 'GET', '/auth/me')).statusCode).toBe(401); // Sitzungen beendet
expect((await login(app, 'lost@example.test')).res.json().status).toBe('ok');
const staffId = await makeUser({ email: 'st@example.test', kind: 'staff', staffRole: 'support' });
expect((await call(app, adm, 'POST', `/admin/users/${staffId}/mfa-reset`)).json().error.code).toBe('PRIVILEGED_ONLY');
const { client: sa } = await staffWithMfa('resetsa@example.test', 'superadmin');
expect((await call(app, sa, 'POST', `/admin/users/${staffId}/mfa-reset`)).statusCode).toBe(200);
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='user.mfa.reset'"))!.n).toBe(2);
});
});
describe('Privat- und Geschäftskunden', () => {
it('erzwingt die Regeln je Kundenart und erlaubt Filter', async () => {
const { client: adm } = await staffWithMfa('type-adm@example.test', 'admin');
const mk = (b: object) => call(app, adm, 'POST', '/admin/customers', b);
// Typ ist Pflicht
expect((await mk({ name: 'X', owner: { email: 'x1@example.test' } })).statusCode).toBe(400);
// Privatkunde: keine Firma / USt-IdNr.
expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { company: 'Firma GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { vatId: 'DE123456789' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
const priv = (await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p1@example.test' }, billing: { street: 'Weg 1', zip: '10115', city: 'Berlin' } })).json();
expect(priv.customerNumber).toMatch(/^K-/);
// Ansprechpartner-Name = Kunde (Owner-Name entfällt)
expect((await one("SELECT name FROM users WHERE email = 'p1@example.test'"))!.name).toBe('Erika Muster');
// Geschäftskunde: ungültige USt-IdNr. abgelehnt, gültige normalisiert; Firma = Name
expect((await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b0@example.test', name: 'Max' }, billing: { vatId: '123' } })).json().error.code).toBe('INVALID_VAT_ID');
const biz = (await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b1@example.test', name: 'Max Muster' }, billing: { vatId: 'de 123.456.789' } })).json();
const d = (await call(app, adm, 'GET', `/admin/customers/${biz.id}`)).json();
expect(d.customerType).toBe('business'); expect(d.billing.vatId).toBe('DE123456789'); expect(d.billing.company).toBe('Muster GmbH');
// Filter
const onlyPriv = (await call(app, adm, 'GET', '/admin/customers?type=private')).json();
expect(onlyPriv.every((c: any) => c.customerType === 'private')).toBe(true);
expect(onlyPriv.some((c: any) => c.id === priv.id)).toBe(true);
// Wechsel Geschäft -> Privat nur, wenn Firma/USt-IdNr. entfernt werden
expect((await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private' })).json().error.code).toBe('PRIVATE_NO_COMPANY');
const sw = await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private', billing: { company: '', vatId: '' } });
expect(sw.statusCode).toBe(200); expect(sw.json().customerType).toBe('private'); expect(sw.json().billing.vatId).toBeNull();
// Privatkunde kann keine Firma bekommen
expect((await call(app, adm, 'PATCH', `/admin/customers/${priv.id}`, { billing: { company: 'Neu GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY');
});
});

View file

@ -0,0 +1,98 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { breakdown, normalizeDomain, parsePriceList, roundPrice, sellPrice, splitDomain } from '../src/modules/domains/logic.js';
import { call, code, login, makeUser } from './helpers.js';
const PASTE = `Domaintyp\tLaufzeit\t0-49\t50-199\t200-999\t1000+\tSetup
com\t12\t12,50\t12,00\t11,00\t10,50\t
com.au\t24\t69,00\t69,00\t68,00\t68,00\t
cy\t12\t69,00\t69,00\t69,00\t69,00\t75,00 €
voting\t12\t1.380,00\t1.360,00\t1.340,00\t1.320,00\t
de\t12\t4,50\t4,00\t3,50\t3,00\t
Laufzeit in Monaten, alle Preise zzgl. Ust.`;
describe('Domain-Logik', () => {
it('liest das eingefügte Preislistenformat (deutsche Zahlen, Setup, Kopf/Fuß)', () => {
const { rows, skipped } = parsePriceList(PASTE);
expect(rows.map((r) => r.tld)).toEqual(['com', 'com.au', 'cy', 'voting', 'de']);
expect(rows[1]).toMatchObject({ termMonths: 24, costs: [6900, 6900, 6800, 6800] });
expect(rows[2]!.setupCents).toBe(7500); expect(rows[3]!.costs[0]).toBe(138000); expect(rows[0]!.setupCents).toBe(0);
expect(skipped.length).toBe(1);
});
it('Verkaufspreis = Einkauf + Aufschlag; ohne Aufschlag kein Preis', () => {
const none = { type: null, value: null };
expect(sellPrice(1250, none, none)).toBeNull();
expect(sellPrice(1250, none, { type: 'percent', value: 2500 })).toBe(1563);
expect(sellPrice(1250, none, { type: 'fixed', value: 300 })).toBe(1550);
expect(sellPrice(1250, { type: 'fixed', value: 100 }, { type: 'percent', value: 2500 })).toBe(1350); // Endungs-Aufschlag hat Vorrang
expect(sellPrice(400, { type: 'percent', value: 0 }, none)).toBe(400);
});
it('Liste brutto: Netto wird herausgerechnet, keine zweite USt; Liste netto: USt wird aufgeschlagen', () => {
const m = { type: 'percent' as const, value: 2000 }, none = { type: null, value: null };
expect(breakdown(1190, none, m, false, 'gross', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
expect(breakdown(1000, none, m, false, 'net', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
});
it('rundet auf ,50 bzw. ,99', () => {
expect([1200, 1230, 1250, 1251, 1298, 1299, 450, 475, 1500].map(roundPrice)).toEqual([1250, 1250, 1250, 1299, 1299, 1299, 450, 499, 1550]);
expect(sellPrice(1250, { type: null, value: null }, { type: 'percent', value: 2000 }, true)).toBe(1550); // 15,00 → 15,50
});
it('normalisiert und zerlegt Domainnamen', () => {
expect(normalizeDomain(' https://www.Beispiel.DE/pfad?x=1 ')).toBe('beispiel.de');
expect(normalizeDomain('müller.de')).toBe('xn--mller-kva.de');
expect(normalizeDomain('a b.de')).toBeNull(); expect(normalizeDomain('-x.de')).toBeNull();
const known = new Set(['uk', 'co.uk', 'de']);
expect(splitDomain('firma.co.uk', known)).toEqual({ label: 'firma', tld: 'co.uk' });
expect(splitDomain('firma.de', known)).toEqual({ label: 'firma', tld: 'de' });
});
});
let app: FastifyInstance; const realFetch = globalThis.fetch;
beforeAll(async () => {
vi.stubGlobal('fetch', (async (url: any, init: any) => {
const u = String(url);
if (u.includes('data.iana.org')) return new Response(JSON.stringify({ services: [[['de', 'com', 'net'], ['https://rdap.test/']]] }), { status: 200 });
if (u.startsWith('https://rdap.test/domain/')) return new Response('{}', { status: u.includes('vergeben') ? 200 : 404 });
return realFetch(url, init);
}) as typeof fetch);
app = await buildApp(); await app.ready();
});
afterAll(() => vi.unstubAllGlobals());
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
describe('Domains: Preisliste, Aufschlag, Prüfung', () => {
it('Import, Aufschlag, Verkaufspreis, Verfügbarkeit; Kunden sehen keinen Einkauf', async () => {
const admin = await staff('dom-admin@example.com', 'admin'); const sup = await staff('dom-support@example.com', 'support');
const imp = await call(app, admin, 'POST', '/admin/domain-tlds/import', { text: PASTE }); expect(imp.statusCode).toBe(200); expect(imp.json()).toMatchObject({ rows: 5, created: 5 });
expect((await call(app, sup, 'POST', '/admin/domain-tlds/import', { text: PASTE })).statusCode).toBe(403);
expect((await call(app, sup, 'GET', '/admin/domain-tlds')).statusCode).toBe(200);
const before = (await call(app, admin, 'GET', '/domains/check?name=frei.com')).json().results[0];
expect(before).toMatchObject({ domain: 'frei.com', status: 'available', method: 'rdap', offered: true, offer: null }); // noch kein Aufschlag → kein Preis
expect((await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: false, margin: { type: 'percent', value: 2000 } })).statusCode).toBe(200);
expect((await call(app, admin, 'PATCH', '/admin/domain-tlds/de', { margin: { type: 'fixed', value: 500 } })).statusCode).toBe(200);
const list = (await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds; const com = list.find((t: any) => t.tld === 'com'); const de = list.find((t: any) => t.tld === 'de');
expect(com).toMatchObject({ costGrossCents: 1250, costNetCents: 1050, priceGrossCents: 1500, priceNetCents: 1261, profitNetCents: 211 }); expect(de).toMatchObject({ costGrossCents: 450, priceGrossCents: 950, priceNetCents: 798 });
await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: true, margin: { type: 'percent', value: 2000 } });
expect((await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds.find((t: any) => t.tld === 'com')).toMatchObject({ priceGrossCents: 1550, priceNetCents: 1303, profitNetCents: 253 });
await makeUser({ email: 'dom-kunde@example.com' }); const { client: cust } = await login(app, 'dom-kunde@example.com');
const r = (await call(app, cust, 'GET', '/domains/check?name=vergeben.com')).json().results[0];
expect(r).toMatchObject({ status: 'registered', offer: { priceGrossCents: 1550, priceNetCents: 1303, termMonths: 12 } }); expect(JSON.stringify(r)).not.toContain('cost');
const multi = (await call(app, cust, 'GET', '/domains/check?name=firma')).json().results; expect(multi.map((x: any) => x.domain).sort()).toEqual(['firma.com', 'firma.de']);
expect((await call(app, cust, 'GET', '/domains/check?name=a%20b.de')).statusCode).toBe(400);
expect((await call(app, cust, 'GET', '/admin/domain-tlds')).statusCode).toBe(403);
// Aufstellung: Einkauf netto, Verkauf netto/brutto (19 %), Beschaffungsstatus
const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200);
expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400);
const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' });
expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' });
expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403);
expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter
await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false });
expect((await call(app, cust, 'GET', '/domains/check?name=frei.com')).json().results[0]).toMatchObject({ offered: false, offer: null });
});
});

View file

@ -0,0 +1,21 @@
import mysql from 'mysql2/promise';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import '../src/core/config.js';
/** Legt eine frische Testdatenbank an (niemals die produktive) und wendet alle Migrationen an. */
export default async function setup() {
const base = { host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD! };
const admin = await mysql.createConnection(base);
await admin.query('DROP DATABASE IF EXISTS kundencenter_test');
await admin.query('CREATE DATABASE kundencenter_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await admin.end();
const c = await mysql.createConnection({ ...base, database: 'kundencenter_test' });
await c.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
const dir = join(process.cwd(), '../../migrations');
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
for (const st of readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) await c.query(st);
}
await c.end();
}

23
apps/api/test/helpers.ts Normal file
View file

@ -0,0 +1,23 @@
import type { FastifyInstance } from 'fastify';
import { randomUUID } from 'node:crypto';
import { hash } from '@node-rs/argon2';
import * as OTPAuth from 'otpauth';
import { run } from '../src/core/db.js';
export interface Client { cookie: string; csrf: string }
export async function makeUser(o: { email: string; kind?: 'customer' | 'staff'; staffRole?: string; password?: string }) {
const id = randomUUID();
await run("INSERT INTO users (id,email,name,password_hash,kind,staff_role,status) VALUES (?,?,?,?,?,?, 'active')", [id, o.email, o.email, await hash(o.password ?? 'correct-horse-battery'), o.kind ?? 'customer', o.staffRole ?? null]);
return id;
}
export async function login(app: FastifyInstance, email: string, password = 'correct-horse-battery'): Promise<{ res: any; client: Client }> {
const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email, password } });
const cookie = (res.cookies[0] ? `${res.cookies[0].name}=${res.cookies[0].value}` : '');
const body = res.json();
return { res, client: { cookie, csrf: body.csrf } };
}
export const call = (app: FastifyInstance, c: Client, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', url: string, payload?: unknown) =>
app.inject({ method, url: `/v1${url}`, payload: payload as never, headers: { cookie: c.cookie, 'x-csrf-token': c.csrf } });
export const code = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate();
/** Nächster gültiger Code (für zweiten Schritt innerhalb desselben Tests, da Wiederverwendung verboten ist). */
export const nextCode = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate({ timestamp: Date.now() + 30000 });

View file

@ -0,0 +1,122 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { runOnce } from '../../worker/src/jobs.js';
import { createFake, type Fake } from '../../../packages/connector-keyhelp/test/fake.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; let fake: Fake; const realFetch = globalThis.fetch;
beforeAll(async () => {
fake = createFake('kh-test-key');
vi.stubGlobal('fetch', ((url: any, init: any) => (String(url).startsWith('https://kh.test') ? fake.fetch(url, init) : realFetch(url, init))) as typeof fetch);
app = await buildApp(); await app.ready();
});
afterAll(() => vi.unstubAllGlobals());
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
const accept = async (link: string, pw: string) => app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(link).searchParams.get('token'), password: pw, repeat: pw } });
describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen', () => {
it('Ende-zu-Ende gegen einen nach der Definition nachgebauten KeyHelp-Server', async () => {
const admin = await staff('kh-adm@x.test', 'admin'); const sa = await staff('kh-sa@x.test', 'superadmin'); const sup = await staff('kh-sup@x.test', 'support');
// ---- Verbindung: nur Superadmin, Geheimnis verschlüsselt, Abgleich liest Konten mit Nutzung
const types = (await call(app, sa, 'GET', '/admin/connector-types')).json(); expect(types.find((t: any) => t.key === 'keyhelp').fields.map((f: any) => f.name)).toEqual(['baseUrl', 'apiKey', 'verifyTls', 'tlsFingerprint']);
const conn = await call(app, sa, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } }); expect(conn.statusCode).toBe(200);
const raw = await one('SELECT secrets_enc FROM connector_instances'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('kh-test-key');
await drain();
const inst = (await call(app, sa, 'GET', '/admin/connectors')).json()[0]; expect(inst.health).toBe('ok'); expect(inst.resources).toBe(2);
expect(inst.capabilities).toEqual(expect.arrayContaining(['customers.list', 'catalog.write', 'children.read', 'children.write', 'sso.login', 'lifecycle.create', 'plan.change']));
const res = (await call(app, admin, 'GET', '/resources')).json(); expect(res.map((r: any) => r.state).sort()).toEqual(['active', 'suspended']);
const alphaRes = res.find((r: any) => r.name.includes('alpha')); const det = (await call(app, admin, 'GET', `/resources/${alphaRes.id}`)).json();
expect(det.usage).toMatchObject({ domains: 2 }); expect(det.limits).toMatchObject({ disk_space: 10737418240 }); expect(det.details.plan).toBe('Starter');
// ---- neuen Tarif anlegen + Produkt definieren
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const planBody = { plan: { name: 'Neu Web 20', limits: { diskSpaceGb: 20, trafficGb: 200, domains: 3, emailAccounts: 10, databases: 3, ftpUsers: 2 }, permissions: { ftp: true, ssh: false } },
product: { sku: 'HOST-NEU-20', status: 'draft', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'email.manage'], version: { name: 'Webhosting Neu 20', taxRateId: t19, priceBasis: 'gross', recurringCents: 499, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30 } } };
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody)).statusCode).toBe(403);
const created = await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody); expect(created.statusCode).toBe(200);
expect(created.json().plan.features).toEqual(expect.arrayContaining(['Speicher: 20 GB', 'Domains: 3', 'FTP']));
const newPlan = fake.state.plans.find((p) => p.name === 'Neu Web 20')!; expect(newPlan.resources.disk_space).toBe(20 * 1024 ** 3);
const prod = (await call(app, admin, 'GET', `/admin/products/${created.json().productId}`)).json(); expect(prod).toMatchObject({ sku: 'HOST-NEU-20', status: 'draft', externalRef: `plan:${newPlan.id}`, provisioning: { hostingPlanId: newPlan.id } });
expect((await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, { ...planBody, product: { ...planBody.product, sku: 'HOST-NEU-21' } })).json().error.code).toBe('PLAN_EXISTS'); // Name schon vergeben
// ---- Produkt zum vorhandenen Tarif "Starter" (Übernahme aus Katalog) für die Bestandsverträge
const starter = (await call(app, admin, 'POST', '/admin/products', { sku: 'HOST-STARTER', category: 'hosting', connectorInstanceId: conn.json().id, externalRef: 'plan:1', status: 'active', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'domain.manage', 'email.manage', 'suspend'],
version: { name: 'Starter', taxRateId: t19, priceBasis: 'gross', recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } } })).json();
// ---- Kunden aus KeyHelp lesen (Kontaktdaten), Rechte
fake.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' };
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.json().id}/customers`)).statusCode).toBe(403);
const cust = (await call(app, admin, 'GET', `/admin/connectors/${conn.json().id}/customers`)).json();
expect(cust.map((c: any) => c.externalRef)).toEqual(['1', '2']); expect(cust[0]).toMatchObject({ displayName: 'Alpha GmbH', imported: null, resourceKnown: true, planRef: 'plan:1' }); expect(cust[0].suggestedProducts.map((p: any) => p.sku)).toEqual(['HOST-STARTER']);
expect(JSON.stringify(cust)).not.toMatch(/GEHEIM|password/i);
// ---- Übernahme: Kunde, Anschrift, Inhaber (eingeladen, KEINE Mail), Konto zugeordnet, Bestandsvertrag
const imp = (items: any[]) => call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items });
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items: [{ externalRef: '1', type: 'business' }] })).statusCode).toBe(403);
const r1 = (await imp([{ externalRef: '1', type: 'business', contract: { productId: starter.id, startedAt: '2025-01-15T00:00:00Z' } }, { externalRef: '2', type: 'private', name: 'Bernd Beta' }])).json().results;
expect(r1.map((r: any) => r.status)).toEqual(['created', 'created']); expect(r1[0].contractNumber).toMatch(/^V-/); expect(r1[1].contractNumber).toBeUndefined();
const orgA = await one("SELECT o.*, b.company, b.street, b.zip, b.city, b.billing_email FROM organizations o JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?", [r1[0].orgId]);
expect(orgA).toMatchObject({ name: 'Alpha GmbH', customer_type: 'business', company: 'Alpha GmbH', street: 'Weg 1', zip: '10115', city: 'Berlin', billing_email: 'alpha@example.test' }); expect(orgA!.legacy_ref).toContain(`keyhelp:${conn.json().id}:1`);
const ownerA = await one("SELECT u.* FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.org_id = ?", [r1[0].orgId]); expect(ownerA).toMatchObject({ email: 'alpha@example.test', status: 'invited', kind: 'customer' });
expect((await one('SELECT COUNT(*) n FROM mail_log'))!.n).toBe(0); // es wurde nichts versendet
const ct = (await call(app, admin, 'GET', `/contracts?org=${r1[0].orgId}`)).json()[0]; expect(ct).toMatchObject({ status: 'active', productName: 'Starter' }); expect(ct.price.imported).toBe(true); expect(ct.price.recurring.gross).toBe(999); expect(ct.resourceId).toBe(alphaRes.id);
expect(new Date(ct.termEnd).getTime()).toBeGreaterThan(Date.now()); // Laufzeit bis in die Zukunft fortgeschrieben
const betaRes = (await call(app, admin, 'GET', `/resources?org=${r1[1].orgId}`)).json(); expect(betaRes).toHaveLength(1);
// Wiederholung / Konflikte
expect((await imp([{ externalRef: '1', type: 'business' }])).json().results[0]).toMatchObject({ status: 'skipped', reason: 'Bereits übernommen' });
expect((await imp([{ externalRef: '999', type: 'business' }])).json().results[0].status).toBe('skipped');
// Verknüpfen mit vorhandenem Kunden statt neu anlegen
fake.state.clients.push({ id: 3, status: 1, username: 'gamma', email: 'alpha@example.test', is_suspended: false, id_hosting_plan: 1, created_at: '2026-03-01 10:00:00', contact_data: { company: 'Gamma' }, permissions: {} }); fake.state.domains.push({ id: 70, id_user: 3, domain: 'gamma.example.test', status: 1, security: {} });
await run("UPDATE connector_instances SET last_sync_at = NULL"); await call(app, sa, 'POST', `/admin/connectors/${conn.json().id}/sync`); await drain();
const dup = (await imp([{ externalRef: '3', type: 'business' }])).json().results[0]; expect(dup.status).toBe('skipped'); expect(dup.reason).toMatch(/bereits einem Benutzer/); // E-Mail vergeben
const lk = (await imp([{ externalRef: '3', type: 'business', linkToOrgId: r1[0].orgId }])).json().results[0]; expect(lk).toMatchObject({ status: 'linked', orgId: r1[0].orgId });
// ---- Einladung gezielt nachträglich senden
expect((await call(app, sup, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).statusCode).toBe(403);
const inv = (await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json(); expect(inv.mail).toBe('not_configured'); expect(inv.inviteLink).toContain('/einladung?token=');
expect((await accept(inv.inviteLink, 'passwort-alpha-123')).statusCode).toBe(200);
expect((await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json().error.code).toBe('NOT_INVITED');
const owner = (await login(app, 'alpha@example.test', 'passwort-alpha-123')).client;
// ---- Kunde nutzt sein Hosting: Panel-Login, Unterobjekte lesen (Mandantentrennung), Änderungen als Auftrag
const cres = (await call(app, owner, 'GET', '/resources')).json().find((r: any) => r.name.includes('alpha'));
const d2 = (await call(app, owner, 'GET', `/resources/${cres.id}`)).json(); expect(d2).toMatchObject({ hasChildren: true, canLogin: true });
const lg = await call(app, owner, 'POST', `/resources/${cres.id}/login`); expect(lg.statusCode).toBe(200); expect(lg.json()).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 }); expect(lg.headers['cache-control']).toBe('no-store');
expect((await call(app, owner, 'POST', `/resources/${betaRes[0].id}/login`)).statusCode).toBe(404); // Konto eines anderen Kunden
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'resource.login'"))[0]!.n).toBe(1); expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('EINMALIG');
const kinds = (await call(app, owner, 'GET', `/resources/${cres.id}/children`)).json(); expect(kinds.kinds.find((k: any) => k.kind === 'email').canWrite).toBe(true); expect(kinds.kinds.find((k: any) => k.kind === 'database').canWrite).toBe(false); expect(kinds.kinds.find((k: any) => k.kind === 'certificate').canWrite).toBe(false);
const doms = (await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`)).json(); expect(doms.map((d: any) => d.name)).toContain('alpha.example.test'); expect(doms.map((d: any) => d.name)).not.toContain('beta.example.test');
const impd = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd.statusCode).toBe(200); expect(impd.json().added).toBeGreaterThanOrEqual(1); expect(impd.json().unpriced).toBeGreaterThanOrEqual(1);
const impd2 = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd2.json().added).toBe(0); // zweiter Lauf legt nichts doppelt an
expect((await call(app, owner, 'GET', `/resources/${cres.id}/children/email`)).json().map((e: any) => e.name)).toEqual(['info@alpha.example.test']);
// Schreiben: Passwortregeln, Freigabe je Art, Ausführung im Hintergrund, Passwort nirgends gespeichert
const post = (kind: string, body: object, r = cres.id) => call(app, owner, 'POST', `/resources/${r}/children/${kind}`, body);
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'kurz' })).json().error.code).toBe('WEAK_PASSWORD');
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' } })).json().error.code).toBe('PASSWORD_REQUIRED');
expect((await post('database', { op: 'create', data: {}, password: 'ein-langes-passwort-1' })).json().error.code).toBe('ACTION_NOT_ALLOWED'); // database.manage nicht freigegeben
expect((await post('certificate', { op: 'delete', id: '60', data: {} })).statusCode).toBe(403);
const ok = await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' }); expect(ok.statusCode).toBe(202);
const jobRow = await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]); expect(JSON.stringify(jobRow!.payload)).not.toContain('sehr-langes'); expect(JSON.stringify(jobRow!.payload)).toContain('secretEnc'); // nur verschlüsselt im Auftrag
await drain();
expect((await one('SELECT status, last_error, payload FROM jobs WHERE id = ?', [ok.json().jobId]))).toMatchObject({ status: 'succeeded' });
expect(JSON.stringify((await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]))!.payload)).not.toContain('secretEnc'); // nach Ausführung entfernt
expect(fake.state.emails.map((e) => e.email)).toContain('kontakt@alpha.example.test');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('sehr-langes'); expect(JSON.stringify(await query('SELECT payload FROM jobs'))).not.toContain('sehr-langes');
// Doppelklick: gleicher Schlüssel = ein Auftrag
const hdr = { cookie: owner.cookie, 'x-csrf-token': owner.csrf, 'idempotency-key': 'mail-doppelt-0001' }; const body = { op: 'create', data: { local: 'zwei', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' };
const a1 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr }); const a2 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr });
expect(a2.json().jobId).toBe(a1.json().jobId); expect(a2.json().duplicate).toBe(true); await drain(); expect(fake.state.emails.filter((e) => e.email === 'zwei@alpha.example.test').length).toBe(1);
// Fremdes Objekt löschen: der Auftrag scheitert, nichts wird verändert
const before = JSON.stringify(fake.state.emails);
const del = await post('email', { op: 'delete', id: '31', data: {} }); expect(del.statusCode).toBe(202); await drain();
expect((await one('SELECT status, last_error FROM jobs WHERE id = ?', [del.json().jobId]))).toMatchObject({ status: 'failed' }); expect(JSON.stringify(fake.state.emails)).toBe(before);
// Eigenes Objekt löschen
const delOwn = await post('email', { op: 'delete', id: String(fake.state.emails.find((e) => e.email === 'zwei@alpha.example.test')!.id), data: {} }); await drain();
expect(fake.state.emails.some((e) => e.email === 'zwei@alpha.example.test')).toBe(false); expect((await one('SELECT status FROM jobs WHERE id = ?', [delOwn.json().jobId]))!.status).toBe('succeeded');
// Sperren durch Kunden (freigegeben) läuft über den bestehenden Auftrag
const sus = await call(app, owner, 'POST', `/resources/${cres.id}/actions`, { action: 'suspend' }); expect(sus.statusCode).toBe(202); await drain(); expect(fake.state.clients.find((c) => c.id === 1)!.is_suspended).toBe(true);
// Anbieter-Ausfall: verständlicher Fehler, kein Absturz
fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0;
});
});

View file

@ -0,0 +1,268 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { enqueue } from '../src/core/jobs.js';
import { processContractLifecycle, provisionOrder } from '@kc/connectors';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, type: 'private' | 'business', name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type, name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const version = (over: object = {}) => ({ name: 'Lizenz Pro', taxRateId: '', setupCents: 1000, recurringCents: 4999, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {}, ...over });
describe('Produkte, Bestellungen, Verträge', () => {
it('Katalog, Bestellung, Freigabe, Bereitstellung, Snapshot, Kündigung, Mandantentrennung', async () => {
resetMock();
const admin = await staff('adm@shop.test', 'admin'); const sa = await staff('sa@shop.test', 'superadmin'); const sup = await staff('sup@shop.test', 'support');
const A = await customer(admin, 'business', 'Firma A', 'a@shop.test'); const B = await customer(admin, 'business', 'Firma B', 'b@shop.test'); const P = await customer(admin, 'private', 'Erika Muster', 'p@shop.test');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Shop', values: { instance: 'shop' } })).json();
await drain();
const tax = (await call(app, admin, 'GET', '/admin/tax-rates')).json(); const t19 = tax.find((t: any) => t.rateBp === 1900).id;
expect(tax.map((t: any) => t.rateBp).sort((x: number, y: number) => x - y)).toEqual([0, 700, 1900]);
// Rechte + Regeln beim Anlegen
const mkBody = (v: object, o: object = {}) => ({ sku: 'LIC-PRO', category: 'license', connectorInstanceId: conn.id, orderableByCustomer: true, requiresApproval: true, customerActions: ['suspend'], status: 'active', version: { ...version({ taxRateId: t19 }), ...v }, ...o });
expect((await call(app, sup, 'POST', '/admin/products', mkBody({}))).statusCode).toBe(403);
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ billingInterval: 'once', recurringCents: 100, termMonths: 0, renewal: 'none' }))).json().error.code).toBe('BAD_TERMS');
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ termMonths: 5 }))).json().error.code).toBe('BAD_TERMS'); // 5 kein Vielfaches von 12
const prod = (await call(app, admin, 'POST', '/admin/products', mkBody({}))).json();
expect((await call(app, admin, 'POST', '/admin/products', mkBody({}))).json().error.code).toBe('SKU_EXISTS');
// Kundenkatalog: Preise serverseitig (Netto/Brutto), nur für eigene Organisation
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
expect(cat).toHaveLength(1); expect(cat[0].price.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 });
expect((await call(app, A.client, 'GET', `/catalog?org=${B.org}`)).statusCode).toBe(404);
// Bestellen: Rabatt verboten, ohne Freigabe nicht ausführbar, fremde Org verboten
const order = (over: object = {}, c = A) => call(app, c.client, 'POST', '/orders', { orgId: c.org, items: [{ productId: prod.id }], ...over });
expect((await order({ items: [{ productId: prod.id, discountBp: 1000 }] })).statusCode).toBe(403);
expect((await call(app, B.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).statusCode).toBe(404);
const o1 = (await order()).json(); expect(o1.status).toBe('pending_approval'); expect(o1.number).toMatch(/^B-/);
expect((await call(app, A.client, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
expect((await call(app, sup, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
expect((await one("SELECT COUNT(*) n FROM resources WHERE org_id = ?", [A.org]))!.n).toBe(0);
expect((await call(app, B.client, 'GET', `/orders/${o1.id}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'GET', '/orders')).json()).toHaveLength(0);
// Freigabe -> Bereitstellung über Worker -> Vertrag aktiv, Ressource beim Kunden
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(200);
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(409); // nicht doppelt freigeben
await drain();
const done = (await call(app, A.client, 'GET', `/orders/${o1.id}`)).json();
expect(done.status).toBe('completed');
const ct = (await call(app, A.client, 'GET', '/contracts')).json()[0];
expect(ct.status).toBe('active'); expect(ct.number).toMatch(/^V-/); expect(ct.resourceId).toBeTruthy();
const months = (new Date(ct.termEnd).getTime() - new Date(ct.startedAt).getTime()) / 86400000; expect(months).toBeGreaterThan(360); expect(months).toBeLessThan(370);
const res = (await call(app, A.client, 'GET', `/resources/${ct.resourceId}`)).json();
expect(res.state).toBe('active'); expect(res.allowedActions).toEqual(['suspend']); // Produktregel begrenzt Kundenaktionen
expect((await call(app, B.client, 'GET', `/resources/${ct.resourceId}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'GET', `/contracts/${ct.id}`)).statusCode).toBe(404);
// Bereitstellung ist idempotent: erneuter Lauf erzeugt nichts Neues
const before = (await one('SELECT COUNT(*) n FROM resources'))!.n;
await run("UPDATE orders SET status='provisioning' WHERE id = ?", [o1.id]);
await provisionOrder(o1.id, { id: 'x', correlationId: 'c', attempt: 1, maxAttempts: 5 });
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(before);
expect((await one('SELECT status FROM orders WHERE id = ?', [o1.id]))!.status).toBe('completed');
// Preis-Snapshot bleibt bei Preisänderung unverändert; neue Bestellung nutzt neue Version
expect((await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, version({ taxRateId: t19, recurringCents: 9999 }))).statusCode).toBe(200);
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().price.recurring.net).toBe(4999);
const cat2 = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json(); expect(cat2[0].price.recurring.net).toBe(9999);
// Personal bestellt mit Rabatt: sofort freigegeben, serverseitig berechnet
const o2 = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id, discountBp: 1000 }] })).json(); expect(o2.status).toBe('provisioning');
await drain();
const o2d = (await call(app, admin, 'GET', `/orders/${o2.id}`)).json();
expect(o2d.status).toBe('completed'); expect(o2d.items[0].snapshot.recurring).toEqual({ net: 8999, tax: 1710, gross: 10709 }); // 99,99 * 0,9 = 89,991
expect(o2d.items[0].snapshot.setup.net).toBe(900);
// Privatkunde: Verbraucherregel (Verlängerung 1 Monat, Frist max. 30 Tage)
const o3 = (await order({}, P)).json(); await call(app, admin, 'POST', `/admin/orders/${o3.id}/approve`); await drain();
const pc = (await call(app, P.client, 'GET', '/contracts')).json()[0];
expect(pc.renewalTermMonths).toBe(1); expect(pc.noticeDays).toBe(30);
// Kündigung: zum Laufzeitende, widerrufbar, Kunde darf nicht sofort beenden
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`, { immediate: true })).json().error.code).toBe('STAFF_ONLY');
expect((await call(app, B.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(404);
const cn = (await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).json();
expect(new Date(cn.cancelEffectiveAt).getTime()).toBe(new Date(ct.termEnd).getTime());
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(409);
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel/revoke`)).statusCode).toBe(200);
await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`);
// Zeitsprung: Kündigung wird wirksam -> Vertrag beendet, Ressource gesperrt (nicht gelöscht)
await run('UPDATE contracts SET cancel_effective_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE) WHERE id = ?', [ct.id]);
const life = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); expect(life.ended).toBe(1);
await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); // idempotent
expect((await query("SELECT id FROM jobs WHERE idempotency_key = ?", [`contract-end:${ct.id}`])).length).toBe(1);
await drain();
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().status).toBe('cancelled');
expect((await call(app, admin, 'GET', `/resources/${ct.resourceId}`)).json().state).toBe('suspended');
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action IN ('order.create','order.approve','contract.activate','contract.cancel.request','contract.cancel.effective')"))!.n).toBeGreaterThanOrEqual(8);
});
it('automatische Verlängerung ohne Kündigung', async () => {
const c = await one("SELECT id FROM contracts WHERE status = 'active' LIMIT 1");
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 DAY) WHERE id = ?', [c!.id]);
const r = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o));
expect(r.renewed).toBeGreaterThanOrEqual(1);
const after = await one('SELECT term_end, status FROM contracts WHERE id = ?', [c!.id]);
expect(new Date(after!.term_end).getTime()).toBeGreaterThan(Date.now()); expect(after!.status).toBe('active');
});
it('Fehlerfälle: unklarer Ausgang wird nie automatisch wiederholt; sichere Fehler begrenzt', async () => {
const admin = await staff('adm2@shop.test', 'admin'); const sa = await staff('sa2@shop.test', 'superadmin');
const C = await customer(admin, 'business', 'Firma C', 'c@shop.test');
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const mk = async (sku: string, failCreate: string) => (await call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: inst!.id, status: 'active', version: version({ taxRateId: t19, provisioning: { failCreate } }) })).json();
const resBefore = (await one('SELECT COUNT(*) n FROM resources'))!.n;
// Timeout: unklar, ob angelegt -> sofort failed, ambiguous, kein Retry
const pT = await mk('FAIL-TIMEOUT', 'timeout');
const oT = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pT.id }] })).json(); await drain();
const dT = (await call(app, admin, 'GET', `/orders/${oT.id}`)).json();
expect(dT.status).toBe('failed'); expect(dT.failureAmbiguous).toBe(true); expect(dT.failureNote).toMatch(/unklar/);
expect((await one("SELECT attempts, status FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oT.id]))).toMatchObject({ attempts: 1, status: 'needs_review' });
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, {})).json().error.code).toBe('CONFIRM_REQUIRED');
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, { confirmChecked: true })).statusCode).toBe(200);
// Verbindung verweigert: sicher nicht gesendet -> begrenzt wiederholt, dann failed (nicht ambiguous)
const pU = await mk('FAIL-UNREACH', 'unreachable');
const oU = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pU.id }] })).json(); await drain();
const dU = (await call(app, admin, 'GET', `/orders/${oU.id}`)).json();
expect(dU.status).toBe('failed'); expect(dU.failureAmbiguous).toBe(false);
expect((await one("SELECT attempts FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oU.id]))!.attempts).toBe(5);
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(resBefore);
// Verträge der fehlgeschlagenen Bestellung bleiben ausstehend (nicht aktiv)
expect((await call(app, admin, 'GET', `/contracts?org=${C.org}`)).json().every((c: any) => c.status === 'pending')).toBe(true);
// Zurückziehen einer gescheiterten Bestellung beendet die Verträge
expect((await call(app, admin, 'POST', `/orders/${oU.id}/cancel`)).statusCode).toBe(200);
});
});
describe('Produktübernahme aus Verbindungen', () => {
it('liest Vorlagen des Anbieters, übernimmt sie mit Details und zählt Übernahmen', async () => {
resetMock();
const admin = await staff('imp-adm@shop.test', 'admin'); const sa = await staff('imp-sa@shop.test', 'superadmin'); const sup = await staff('imp-sup@shop.test', 'support');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Import', values: { instance: 'imp' } })).json();
await drain();
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.id}/catalog`)).statusCode).toBe(403);
const cat = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
expect(cat.map((c: any) => c.externalRef)).toEqual(['mock-prog-1', 'mock-prog-2']);
expect(cat.every((c: any) => c.importedProducts === 0)).toBe(true);
expect(cat[0].hints[0].label).toBe('5 Benutzer');
// Übernahme mit selbst definierten Details (zwei Varianten desselben Angebots)
const imp = (sku: string, over: object = {}) => call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: conn.id, externalRef: 'mock-prog-1', orderableByCustomer: true, requiresApproval: false, customerActions: ['suspend'],
version: { name: 'Alpha Jahreslizenz', taxRateId: t19, setupCents: 0, recurringCents: 12000, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: { userLimit: 5 }, ...over } });
const a1 = await imp('ALPHA-Y'); expect(a1.statusCode).toBe(200);
expect((await imp('ALPHA-M', { name: 'Alpha Monatslizenz', recurringCents: 1200, billingInterval: 'monthly', termMonths: 0, renewalTermMonths: 1 })).statusCode).toBe(200);
const cat2 = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-1').importedProducts).toBe(2);
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-2').importedProducts).toBe(0);
const p = (await call(app, admin, 'GET', `/admin/products/${a1.json().id}`)).json(); expect(p.externalRef).toBe('mock-prog-1');
// Herkunft nur mit Verbindung
expect((await call(app, admin, 'POST', '/admin/products', { sku: 'X-1', category: 'license', externalRef: 'x', version: { name: 'X', taxRateId: t19, billingInterval: 'once', provisioning: {} } })).json().error.code).toBe('BAD_CONNECTOR');
// Ausfall des Anbieters: verständlicher Fehler statt Absturz
await call(app, sa, 'PATCH', `/admin/connectors/${conn.id}`, { values: { simulateOutage: 'true' } });
const down = await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`);
expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
});
});
describe('Bruttopreise', () => {
it('bestellt zu exakten Endkundenpreisen (Privatkunde 9,99 € brutto) und speichert die Basis im Snapshot', async () => {
const admin = await staff('gross-adm@shop.test', 'admin');
const P = await customer(admin, 'private', 'Erika Muster', 'gross-p@shop.test');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'GROSS-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false,
version: { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} } })).json();
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
expect(cat[0].price.recurring).toEqual({ net: 839, tax: 160, gross: 999 }); expect(cat[0].price.basis).toBe('gross');
const o = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] })).json();
await drain();
const d = (await call(app, P.client, 'GET', `/orders/${o.id}`)).json();
expect(d.status).toBe('completed'); expect(d.items[0].snapshot.recurring.gross).toBe(999); expect(d.items[0].snapshot.basis).toBe('gross');
// Preisänderung gilt nur für neue Bestellungen; alter Vertrag bleibt 9,99 €
await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', recurringCents: 1299, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} });
expect((await call(app, P.client, 'GET', '/contracts')).json()[0].price.recurring.gross).toBe(999);
});
});
describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => {
resetMock();
const admin = await staff('ft-adm@shop.test', 'admin'); const sa = await staff('ft-sa@shop.test', 'superadmin'); const sup = await staff('ft-sup@shop.test', 'support');
const P = await customer(admin, 'private', 'Erika Muster', 'ft-p@shop.test');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-FT', values: { instance: 'ft' } })).json(); await drain();
expect((await call(app, sup, 'GET', '/admin/product-bundles')).statusCode).toBe(403);
const bundles = (await call(app, admin, 'GET', '/admin/product-bundles')).json();
const ft = bundles.find((b: any) => b.key === 'familytool-vorlage'); expect(ft.products.map((p: any) => p.sku)).toEqual(['FT-PREMIUM-M', 'FT-PREMIUM-Y', 'FT-UNLIMITED-M', 'FT-UNLIMITED-Y', 'FT-LIFETIME', 'FT-TRIAL']);
expect(ft.products.find((p: any) => p.sku === 'FT-UNLIMITED-M').recurringCents).toBe(999);
const skus = ft.products.map((p: any) => p.sku);
const imp = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
expect(imp.created).toHaveLength(6); expect(imp.skipped).toHaveLength(0);
// nochmals importieren: alles übersprungen (Artikelnummern existieren), nichts doppelt
const again = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
expect(again.created).toHaveLength(0); expect(again.skipped).toHaveLength(6);
const list = (await call(app, admin, 'GET', '/admin/products')).json(); expect(list.filter((p: any) => p.sku.startsWith('FT-')).every((p: any) => p.status === 'draft')).toBe(true);
const byS = (s: string) => list.find((p: any) => p.sku === s);
expect(byS('FT-PREMIUM-Y').provisioning).toMatchObject({ keyPrefix: 'PREMIUM', durationType: 'YEAR' });
// Aktivierungssperre: Anbieter meldet Präfix-Unterstützung nicht -> 400, Entwurf bleibt
await run("UPDATE connector_instances SET capabilities_json = JSON_REMOVE(capabilities_json, '$[11]', '$[10]') WHERE id = ?", [conn.id]);
const caps = (await one('SELECT capabilities_json c FROM connector_instances WHERE id = ?', [conn.id]))!.c; expect(JSON.stringify(caps)).not.toContain('license.key_prefix');
const blocked = await call(app, admin, 'PATCH', `/admin/products/${byS('FT-PREMIUM-M').id}`, { status: 'active' });
expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('NEEDS_LICENSE_EXTENSION');
// Unlimited ohne Präfix ist trotzdem aktivierbar
expect((await call(app, admin, 'PATCH', `/admin/products/${byS('FT-UNLIMITED-M').id}`, { status: 'active' })).statusCode).toBe(200);
// Nach Erweiterung (Abgleich meldet Fähigkeit wieder) sind alle aktivierbar
await call(app, sa, 'POST', `/admin/connectors/${conn.id}/sync`); await drain();
for (const s of ['FT-PREMIUM-M', 'FT-PREMIUM-Y']) expect((await call(app, admin, 'PATCH', `/admin/products/${byS(s).id}`, { status: 'active' })).statusCode).toBe(200);
// Privatkunde bestellt Premium monatlich: exakt 2,99 € brutto, Edition PREMIUM, rollierender Monatsvertrag
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
expect(cat.find((c: any) => c.sku === 'FT-PREMIUM-M').price.recurring).toEqual({ net: 251, tax: 48, gross: 299 });
const oM = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-PREMIUM-M').id }] })).json(); expect(oM.status).toBe('provisioning'); await drain();
const cM = (await call(app, P.client, 'GET', '/contracts')).json()[0];
expect(cM.status).toBe('active'); expect(cM.termEnd).toBeTruthy(); // Verlängerungstakt trotz Mindestlaufzeit 0
const rM = (await call(app, P.client, 'GET', `/resources/${cM.resourceId}`)).json(); expect(rM.details.edition).toBe('PREMIUM');
// Kunde und Herkunft wurden an den Anbieter gemeldet
expect(rM.details.context).toMatchObject({ source: 'kundencenter', customerName: 'Erika Muster', customerEmail: 'ft-p@shop.test', contractNumber: cM.number });
expect(rM.details.context.customerNumber).toMatch(/^K-\d+$/); expect(rM.details.context.orderNumber).toMatch(/^B-\d+$/);
// Vertragsverlängerung zieht die Lizenz mit
const until0 = new Date(rM.validUntil).getTime();
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY) WHERE id = ?', [cM.id]);
expect((await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o))).renewed).toBe(1); await drain();
const after = (await call(app, admin, 'GET', `/resources/${cM.resourceId}`)).json();
const cAfter = (await call(app, admin, 'GET', `/contracts/${cM.id}`)).json();
expect(new Date(after.validUntil).toISOString()).toBe(new Date(cAfter.termEnd).toISOString()); expect(new Date(after.validUntil).getTime()).toBeGreaterThan(until0 - 86400000);
// Lifetime: Freigabe nötig, Edition LIFETIME, unbefristet, keine Verlängerung
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-LIFETIME').id}`, { status: 'active' });
const oL = (await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-LIFETIME').id }] })).json(); await drain();
const cL = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.id !== cM.id);
expect(cL.termEnd).toBeNull(); expect((await call(app, admin, 'GET', `/resources/${cL.resourceId}`)).json().details.edition).toBe('LIFETIME');
expect(cL.price.setup.gross).toBe(15900);
// Trial: 14 Tage Gültigkeit, Edition TRIAL
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-TRIAL').id}`, { status: 'active' });
await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-TRIAL').id }] }); await drain();
const trial = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.price.sku === 'FT-TRIAL');
const tr = (await call(app, admin, 'GET', `/resources/${trial.resourceId}`)).json(); expect(tr.details.edition).toBe('TRIAL');
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
});
});

View file

@ -0,0 +1,65 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
describe('Schlüssel auf Abruf', () => {
it('zeigt den vollständigen Schlüssel nur Berechtigten, nie in Listen/Details/Audit', async () => {
resetMock();
const admin = await staff('rv-adm@x.test', 'admin'); const sa = await staff('rv-sa@x.test', 'superadmin'); const sup = await staff('rv-sup@x.test', 'support');
const A = await customer(admin, 'Firma A', 'rv-a@x.test'); const B = await customer(admin, 'Firma B', 'rv-b@x.test');
// Mitglied ohne Verwaltungsrecht in A
const inv = (await call(app, A.client, 'POST', `/orgs/${A.org}/invitations`, { email: 'rv-m@x.test', name: 'M', role: 'member' })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } });
const member = (await login(app, 'rv-m@x.test', 'member-passwort-1')).client;
await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Reveal', values: { instance: 'rv' } }); await drain();
const res = (await call(app, admin, 'GET', '/resources')).json()[0];
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org });
// Liste: Schlüsselfeld nur für Berechtigte
expect((await call(app, A.client, 'GET', '/resources')).json()[0].canReveal).toBe(true);
expect((await call(app, member, 'GET', '/resources')).json()[0].canReveal).toBe(false);
expect((await call(app, sup, 'GET', '/resources')).json()[0].canReveal).toBe(false);
const d = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
expect(d.canReveal).toBe(true); expect((await call(app, member, 'GET', `/resources/${res.id}`)).json().canReveal).toBe(false);
expect(JSON.stringify(d)).not.toContain('MOCK-KEY'); // Details enthalten den Schlüssel nie
const ok = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`);
expect(ok.statusCode).toBe(200); expect(ok.json().items).toEqual([{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${res.externalRef ?? 'm-1'}` }]);
expect(ok.headers['cache-control']).toBe('no-store'); expect(ok.json().hideAfterSec).toBe(60);
expect((await call(app, admin, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(200); // Personal mit Schreibrecht
expect((await call(app, member, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Mitglied darf nicht
expect((await call(app, sup, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Support nur lesend
expect((await call(app, B.client, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(404); // fremde Organisation
expect((await call(app, A.client, 'POST', `/resources/${res.id}/reveal`, undefined)).statusCode).toBe(200);
const anon = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/reveal` }); expect(anon.statusCode).toBe(401);
// Audit: Abruf ist protokolliert, der Schlüssel selbst nirgends
const ev = await query("SELECT actor_id, org_id, after_json FROM audit_events WHERE action = 'resource.reveal'"); expect(ev.length).toBeGreaterThanOrEqual(3);
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('MOCK-KEY');
expect(JSON.stringify(await query('SELECT * FROM resources'))).not.toContain('MOCK-KEY');
// Anbieter-Ausfall: verständlicher Fehler
const inst = await one('SELECT id FROM connector_instances LIMIT 1');
await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]);
const down = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`); expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
});
});

17
apps/api/test/setup.ts Normal file
View file

@ -0,0 +1,17 @@
import { beforeAll } from 'vitest';
import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
await c.query('SET FOREIGN_KEY_CHECKS = 0');
for (const t of DATA) await c.query(`TRUNCATE TABLE \`${t}\``);
await c.query('SET FOREIGN_KEY_CHECKS = 1');
await c.query("UPDATE customer_sequences SET next_value = 10000; UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END".split(';')[0]);
await c.query("UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END");
await c.query('UPDATE domain_settings SET tier = 1, margin_type = NULL, margin_value = NULL');
await c.end();
});

1
apps/api/tsconfig.json Normal file
View file

@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"] }

View file

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['test/global-setup.ts'], setupFiles: ['test/setup.ts'], env: { DB_NAME: 'kundencenter_test', KC_NODE_ENV: 'test', KC_BASE_URL: 'http://localhost:4101' }, fileParallelism: false, testTimeout: 30000 } });

7
apps/web/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

14
apps/web/next.config.mjs Normal file
View file

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
export default {
poweredByHeader: false,
async redirects() { return [{ source: '/admin/connectors', destination: '/einstellungen/verbindungen', permanent: true }]; },
async rewrites() { return [{ source: '/api/:path*', destination: `${process.env.KC_API_URL ?? 'http://127.0.0.1:4100'}/v1/:path*` }]; },
async headers() {
return [{ source: '/:path*', headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'same-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
] }];
},
};

25
apps/web/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "@kc/web",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "next dev -p 4101 -H 127.0.0.1",
"build": "next build",
"start": "next start -p 4101 -H 127.0.0.1",
"typecheck": "tsc --noEmit",
"test": "echo 'keine Web-Tests'"
},
"dependencies": {
"next": "^16.3.6",
"qrcode": "^1.5.4",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/node": "^26.6.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"typescript": "^7.0.2"
}
}

View file

@ -0,0 +1,21 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api, errMsg } from '@/lib/api';
import { Alert, Empty, fmt } from '@/components/ui';
interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null }
export default function Audit() {
const [list, setList] = useState<Ev[] | null>(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null);
const load = useCallback(() => api<Ev[]>('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
return (<>
<div className="row between"><h1>Audit-Protokoll</h1><button className="btn" onClick={() => api<{ checked: number; brokenAt: number | null }>('GET', '/admin/audit/verify').then(setChain).catch((e) => setErr(errMsg(e)))}>Integrität prüfen</button></div>
{err && <Alert kind="err">{err}</Alert>}
{chain && (chain.brokenAt === null ? <Alert kind="ok">Hash-Kette intakt ({chain.checked} Einträge geprüft).</Alert> : <Alert kind="err">Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.</Alert>)}
<div className="card"><div className="field"><label htmlFor="a">Aktion beginnt mit</label><input id="a" value={action} onChange={(e) => setAction(e.target.value)} placeholder="z. B. auth. oder customer." /></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Einträge" /> :
<div className="tablewrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Akteur</th><th>Objekt</th><th>Ergebnis</th></tr></thead><tbody>
{list.map((e) => <tr key={e.id}><td>{fmt(e.ts)}</td><td className="mono">{e.action}</td><td className="mono small">{e.actorType}{e.actorId ? ` ${e.actorId.slice(0, 8)}` : ''}</td><td className="mono small">{e.resourceType ?? ''} {e.resourceId?.slice(0, 8) ?? ''}</td>
<td><span className={`badge ${e.result === 'success' ? 'ok' : 'err'}`}><span aria-hidden="true">{e.result === 'success' ? '✓' : '✕'}</span>{e.result === 'success' ? 'Erfolg' : e.result === 'denied' ? 'Abgelehnt' : 'Fehler'}</span></td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,23 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, JOB_LABEL, JobStatus, fmt } from '@/components/ui';
interface J { id: string; type: string; status: string; attempts: number; maxAttempts: number; error: string | null; createdAt: string; correlationId: string | null }
const TYPE: Record<string, string> = { 'connector.sync': 'Abgleich', 'connector.execute': 'Aktion beim Anbieter', 'discord.notify': 'Discord-Benachrichtigung' };
export default function Auftraege() {
const { can } = useSession(); const w = can('resources.write');
const [list, setList] = useState<J[] | null>(null); const [status, setStatus] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
const load = useCallback(() => api<J[]>('GET', `/admin/jobs${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [status]);
useEffect(() => { void load(); const t = setInterval(() => void load(), 5000); return () => clearInterval(t); }, [load]);
async function retry(j: J) { try { await api('POST', `/admin/jobs/${j.id}/retry`); setMsg({ k: 'ok', t: 'Auftrag wird erneut ausgeführt.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
return (<>
<h1>Aufträge</h1>{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<div className="card"><Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option>{Object.entries(JOB_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Aufträge" /> :
<div className="tablewrap"><table><thead><tr><th>Art</th><th>Status</th><th>Versuche</th><th>Angelegt</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
{list.map((j) => <tr key={j.id}><td>{TYPE[j.type] ?? j.type}<div className="small muted mono">{j.correlationId?.slice(0, 8)}</div></td><td><JobStatus value={j.status} />{j.error && <div className="small muted">{j.error}</div>}</td><td>{j.attempts}/{j.maxAttempts}</td><td>{fmt(j.createdAt)}</td>
<td>{w && (j.status === 'needs_review' || j.status === 'failed') && <button className="btn small" onClick={() => retry(j)}>Erneut ausführen</button>}</td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,45 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, ROLE_LABEL, Status } from '@/components/ui';
interface U { id: string; email: string; name: string; kind: 'customer' | 'staff'; staffRole: string | null; status: string; mfa: boolean }
export default function Benutzer() {
const { me, can } = useSession();
const [list, setList] = useState<U[] | null>(null); const [q, setQ] = useState(''); const [kind, setKind] = useState(''); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [open, setOpen] = useState(false); const [resetFor, setResetFor] = useState<U | null>(null);
const load = useCallback(() => api<U[]>('GET', `/admin/users?${new URLSearchParams({ ...(q ? { q } : {}), ...(kind ? { kind } : {}) })}`).then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [q, kind]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
const priv = can('users.write_privileged');
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget);
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', '/admin/users', { name: f.get('name'), email: f.get('email'), staffRole: f.get('role') });
setMsg(r.mail === 'sent' ? { k: 'ok', t: 'Einladung wurde versendet.' } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); setOpen(false); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function toggle(u: U) {
try { await api('PATCH', `/admin/users/${u.id}`, { status: u.status === 'active' ? 'disabled' : 'active' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function resetMfa(u: U) {
try { await api('POST', `/admin/users/${u.id}/mfa-reset`); setResetFor(null); setMsg({ k: 'ok', t: `2FA von ${u.name} wurde zurückgesetzt und alle Sitzungen beendet. ${u.kind === 'staff' ? 'Beim nächsten Login muss die 2FA neu eingerichtet werden.' : 'Der Kunde kann sie unter „Mein Konto“ neu einrichten.'}` }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
<div className="row between"><h1>Benutzer</h1>{can('users.write') && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Mitarbeiter anlegen</button>}</div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neuer Mitarbeiter</h2><div className="cols">
<Field id="name" label="Name"><input id="name" name="name" required autoFocus /></Field><Field id="email" label="E-Mail"><input id="email" name="email" type="email" required /></Field>
<Field id="role" label="Rolle"><select id="role" name="role"><option value="support">Support</option><option value="accounting">Buchhaltung</option>{priv && <><option value="admin">Administrator</option><option value="superadmin">Superadministrator</option></>}</select></Field></div>
<p className="small muted">Mitarbeiter müssen bei der ersten Anmeldung 2FA einrichten.</p><button className="btn primary" type="submit">Einladung senden</button></form>}
{resetFor && <div className="card" role="alertdialog" aria-labelledby="rt"><p id="rt"><strong>2FA von {resetFor.name} zurücksetzen?</strong> Alle Sitzungen des Benutzers werden beendet. Bitte nur nach Prüfung der Identität ausführen. Die Aktion wird protokolliert.</p>
<div className="row"><button className="btn danger" onClick={() => resetMfa(resetFor)}>Ja, zurücksetzen</button><button className="btn" onClick={() => setResetFor(null)}>Abbrechen</button></div></div>}
<div className="card"><div className="cols">
<Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} /></Field>
<Field id="kind" label="Typ"><select id="kind" value={kind} onChange={(e) => setKind(e.target.value)}><option value="">Alle</option><option value="staff">Mitarbeiter</option><option value="customer">Kunden</option></select></Field></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Benutzer gefunden" /> :
<div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>2FA</th><th>Status</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
{list.map((u) => <tr key={u.id}><td>{u.name}</td><td>{u.email}</td><td>{u.staffRole ? ROLE_LABEL[u.staffRole] : 'Kunde'}</td><td>{u.mfa ? 'Ja' : 'Nein'}</td><td><Status value={u.status} /></td>
<td><div className="row">{can('users.write') && u.id !== me?.user.id && u.status !== 'invited' && (u.staffRole !== 'admin' && u.staffRole !== 'superadmin' || priv) && <button className="btn small" onClick={() => toggle(u)}>{u.status === 'active' ? 'Sperren' : 'Entsperren'}</button>}
{can('users.write') && u.id !== me?.user.id && u.mfa && (u.kind === 'customer' || priv) && <button className="btn small" onClick={() => setResetFor(u)}>2FA zurücksetzen</button>}</div></td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,11 @@
'use client';
import Link from 'next/link';
import { DomainRecords } from '@/components/DomainRecords';
export default function Aufstellung() {
return (<>
<div className="row between"><h1>Domain-Aufstellung</h1><Link className="btn" href="/admin/produkte/domains">Preisliste &amp; Aufschlag</Link></div>
<p className="muted">Je Domain: <strong>Einkauf</strong> (Preisliste von KCS, dort Bruttopreise inkl. USt; netto herausgerechnet) und Ihr <strong>errechneter Verkaufspreis</strong> brutto und netto. Die USt wird nicht doppelt aufgeschlagen. Damit sehen Sie, was Sie bei KCS bestellen müssen und was Sie berechnen.</p>
<DomainRecords />
</>);
}

View file

@ -0,0 +1,81 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { SecretField } from '@/components/SecretField';
import { DomainRecords } from '@/components/DomainRecords';
import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui';
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
interface CRow { id: string; number: string; status: string; productName: string; termEnd: string | null; cancelEffectiveAt: string | null }
interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] }
interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean }
export default function Kunde() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const [contracts, setContracts] = useState<CRow[] | null>(null); const [orders, setOrders] = useState<ORow[] | null>(null); const [ress, setRess] = useState<RRow[] | null>(null);
const [o, setO] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState<string | null>(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null);
const load = useCallback(() => api<Org>('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); api<CRow[]>('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api<ORow[]>('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api<RRow[]>('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]);
if (!o) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const w = can('customers.write');
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => (f.get(k) as string | null) ?? '';
const priv = o!.customerType === 'private';
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { name: g('name'), billing: { street: g('street'), zip: g('zip'), city: g('city'), billingEmail: g('billingEmail'), phone: g('phone'), ...(priv ? {} : { company: g('company'), vatId: g('vatId') }) } })); setMsg({ k: 'ok', t: 'Gespeichert.' }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function changeType(t: 'private' | 'business') {
// Beim Wechsel zum Privatkunden werden Firma und USt-IdNr. entfernt (Bestätigung vorher).
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { customerType: t, ...(t === 'private' ? { billing: { company: '', vatId: '' } } : { billing: { company: o!.name } }) })); setSwitchTo(null); setMsg({ k: 'ok', t: 'Kundenart geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function reinvite(uid: string, email: string) {
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', `/admin/users/${uid}/reinvite`); setMsg(r.mail === 'sent' ? { k: 'ok', t: `Die Einladung wurde an ${email} gesendet.` } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${r.inviteLink}` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function setStatus(status: string) {
try { setO(await api<Org>('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<div className="row between"><h1>{o.name}</h1><Status value={o.status} /></div>
<p className="muted"><span className="mono">{o.customerNumber}</span> · <span className="badge info">{o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</span></p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<form className="card" onSubmit={save}><h2>Stammdaten &amp; Rechnungsanschrift</h2>
<div className="cols"><Field id="name" label={o.customerType === 'business' ? 'Firmenname' : 'Name'}><input id="name" name="name" defaultValue={o.name} disabled={!w} required /></Field>
{o.customerType === 'business' && <Field id="company" label="Firma (Rechnung)"><input id="company" name="company" defaultValue={o.billing.company ?? ''} disabled={!w} /></Field>}
<Field id="street" label="Straße"><input id="street" name="street" defaultValue={o.billing.street ?? ''} disabled={!w} /></Field>
<Field id="zip" label="PLZ"><input id="zip" name="zip" defaultValue={o.billing.zip ?? ''} disabled={!w} /></Field>
<Field id="city" label="Ort"><input id="city" name="city" defaultValue={o.billing.city ?? ''} disabled={!w} /></Field>
{o.customerType === 'business' && <Field id="vatId" label="USt-IdNr."><input id="vatId" name="vatId" defaultValue={o.billing.vatId ?? ''} disabled={!w} /></Field>}
<Field id="billingEmail" label="Rechnungs-E-Mail"><input id="billingEmail" name="billingEmail" type="email" defaultValue={o.billing.billingEmail ?? ''} disabled={!w} /></Field>
<Field id="phone" label="Telefon"><input id="phone" name="phone" defaultValue={o.billing.phone ?? ''} disabled={!w} /></Field></div>
{w && <button className="btn primary" type="submit">Speichern</button>}</form>
<div className="card"><div className="row between"><h2>Verträge</h2>{can('orders.write') && <Link className="btn small" href="/bestellen">Bestellung anlegen</Link>}</div>
{contracts === null ? <p className="muted" role="status">Wird geladen …</p> : contracts.length === 0 ? <Empty title="Noch keine Verträge" /> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Produkt</th><th>Laufzeit bis</th><th>Status</th></tr></thead><tbody>
{contracts.map((c) => <tr key={c.id}><td className="mono"><Link href={`/vertraege/${c.id}`}>{c.number}</Link></td><td>{c.productName}</td><td>{c.termEnd ? fmt(c.termEnd) : 'unbefristet'}{c.cancelEffectiveAt && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigt zum {fmt(c.cancelEffectiveAt)}</span></div>}</td><td><ContractStatusBadge value={c.status} /></td></tr>)}</tbody></table></div>}</div>
<div className="card"><h2>Ressourcen und Lizenzen</h2>
{ress === null ? <p className="muted" role="status">Wird geladen …</p> : ress.length === 0 ? <Empty title="Keine Ressourcen zugeordnet" /> :
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Status</th><th>Gültig bis</th><th>Lizenzschlüssel</th></tr></thead><tbody>
{ress.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td><td><ResState value={r.state} /></td><td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td></tr>)}</tbody></table></div>}</div>
{can('domains.read') && <section aria-labelledby="h-dom"><h2 id="h-dom">Domains</h2><DomainRecords orgId={String(id)} /></section>}
<div className="card"><h2>Bestellungen</h2>
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>
{orders.map((x) => <tr key={x.id}><td className="mono"><Link href="/bestellungen">{x.number}</Link></td><td>{x.items.map((i) => i.snapshot.name).join(', ')}</td><td>{fmt(x.createdAt)}</td><td><OrderStatusBadge value={x.status} /></td></tr>)}</tbody></table></div>}</div>
<div className="card"><h2>Benutzer</h2><div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th><span className="sr">Aktionen</span></th></tr></thead><tbody>
{o.members.map((m) => <tr key={m.id}><td>{m.name}</td><td>{m.email}</td><td>{ROLE_LABEL[m.role]}</td><td><Status value={m.status} /></td><td>{w && m.status === 'invited' && <button className="btn small" onClick={() => reinvite(m.id, m.email)}>Einladung senden</button>}</td></tr>)}</tbody></table></div></div>
{w && <div className="card"><h2>Kundenart</h2>
{switchTo ? (<div role="alertdialog" aria-labelledby="st"><p id="st"><strong>Zu {switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde'} wechseln?</strong> {switchTo === 'private' ? 'Firma und USt-IdNr. werden entfernt.' : 'Als Firma wird der bisherige Name übernommen.'} Das kann Auswirkungen auf Rechnungen und Steuer haben. Die Änderung wird protokolliert.</p>
<div className="row"><button className="btn primary" onClick={() => changeType(switchTo)}>Ja, ändern</button><button className="btn" onClick={() => setSwitchTo(null)}>Abbrechen</button></div></div>)
: <button className="btn" onClick={() => setSwitchTo(o.customerType === 'business' ? 'private' : 'business')}>Zu {o.customerType === 'business' ? 'Privatkunde' : 'Geschäftskunde'} ändern</button>}</div>}
{w && <div className="card"><h2>Gefährliche Aktionen</h2>
{confirm ? (<div role="alertdialog" aria-labelledby="ct"><p id="ct"><strong>{confirm === 'suspended' ? 'Kunde sperren?' : confirm === 'closed' ? 'Kunde beenden?' : 'Kunde entsperren?'}</strong> Die Aktion wird protokolliert.</p>
<div className="row"><button className="btn danger" onClick={() => setStatus(confirm)}>Ja, ausführen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)
: <div className="row">{o.status !== 'active' && o.status !== 'closed' && <button className="btn" onClick={() => setConfirm('active')}>Entsperren</button>}
{o.status === 'active' && <button className="btn danger" onClick={() => setConfirm('suspended')}>Sperren</button>}
{o.status !== 'closed' && <button className="btn danger" onClick={() => setConfirm('closed')}>Beenden</button>}</div>}
<p className="small muted" style={{ marginTop: 12 }}>Hinweis: Sperren wirkt aktuell nur auf den Kundenstatus. Provider-Ressourcen werden erst mit den Connectoren gesperrt.</p></div>}
</>);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, Status } from '@/components/ui';
interface Cust { id: string; customerNumber: string; name: string; customerType: 'private' | 'business'; status: string; city: string | null; billingEmail: string | null; members: number }
export default function Kunden() {
const { can } = useSession();
const [list, setList] = useState<Cust[] | null>(null); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [open, setOpen] = useState(false); const [ctype, setCtype] = useState<'private' | 'business'>('business'); const [typeFilter, setTypeFilter] = useState('');
const [created, setCreated] = useState<{ id: string; customerNumber: string; mail: string; inviteLink?: string } | null>(null);
const load = useCallback(() => api<Cust[]>('GET', `/admin/customers?${new URLSearchParams({ ...(q ? { q } : {}), ...(typeFilter ? { type: typeFilter } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [q, typeFilter]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget); const s = (k: string) => (f.get(k) as string) || undefined;
try {
const priv = ctype === 'private';
const name = priv ? `${f.get('firstName')} ${f.get('lastName')}`.trim() : (f.get('name') as string);
const r = await api<NonNullable<typeof created>>('POST', '/admin/customers', {
type: ctype, name, owner: priv ? { email: f.get('ownerEmail') } : { name: f.get('ownerName'), email: f.get('ownerEmail') },
billing: { street: s('street'), zip: s('zip'), city: s('city'), country: s('country') ?? 'DE', phone: s('phone'), ...(priv ? {} : { vatId: s('vatId') }) } });
setCreated(r); setOpen(false); void load();
} catch (x) { setErr(errMsg(x)); }
}
return (<>
<div className="row between"><h1>Kunden</h1>{can('customers.write') && <div className="row"><Link className="btn" href="/admin/kunden/uebernahme">Aus Verbindung übernehmen</Link><button className="btn primary" onClick={() => { setOpen(!open); setCreated(null); }} aria-expanded={open}>Kunde anlegen</button></div>}</div>
{err && <Alert kind="err">{err}</Alert>}
{created && <Alert kind={created.mail === 'sent' ? 'ok' : 'warn'}>Kunde {created.customerNumber} angelegt. {created.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : <>E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): <code>{created.inviteLink}</code></>}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neuer Kunde</h2>
<fieldset style={{ border: 0, padding: 0, margin: '0 0 16px' }}><legend style={{ fontWeight: 600, marginBottom: 8 }}>Kundenart</legend>
<div className="row">{([['business', 'Geschäftskunde'], ['private', 'Privatkunde']] as const).map(([v, l]) => <label key={v} className="row"><input type="radio" name="ctype" style={{ width: 20, minHeight: 20 }} checked={ctype === v} onChange={() => setCtype(v)} />{l}</label>)}</div></fieldset>
{ctype === 'business' ? (<>
<div className="cols"><Field id="name" label="Firmenname"><input id="name" name="name" required autoFocus /></Field><Field id="vatId" label="USt-IdNr." hint="Optional, z. B. DE123456789"><input id="vatId" name="vatId" /></Field></div>
<h3>Ansprechpartner (erhält die Einladung)</h3>
<div className="cols"><Field id="ownerName" label="Name"><input id="ownerName" name="ownerName" required /></Field><Field id="ownerEmail" label="E-Mail"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
</>) : (<>
<div className="cols"><Field id="firstName" label="Vorname"><input id="firstName" name="firstName" required autoFocus /></Field><Field id="lastName" label="Nachname"><input id="lastName" name="lastName" required /></Field>
<Field id="ownerEmail" label="E-Mail (erhält die Einladung)"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
</>)}
<h3>Rechnungsanschrift</h3>
<div className="cols"><Field id="street" label="Straße"><input id="street" name="street" /></Field><Field id="zip" label="PLZ"><input id="zip" name="zip" /></Field><Field id="city" label="Ort"><input id="city" name="city" /></Field>
<Field id="country" label="Land (ISO)"><input id="country" name="country" defaultValue="DE" maxLength={2} /></Field><Field id="phone" label="Telefon"><input id="phone" name="phone" /></Field></div>
<div className="row"><button className="btn primary" type="submit">Kunde anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card">
<div className="cols"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, Kundennummer oder E-Mail" /></Field>
<Field id="tf" label="Kundenart"><select id="tf" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}><option value="">Alle</option><option value="business">Geschäftskunden</option><option value="private">Privatkunden</option></select></Field></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title={q ? 'Keine Treffer' : 'Noch keine Kunden'}>{!q && 'Legen Sie den ersten Kunden über „Kunde anlegen“ an.'}</Empty> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Name</th><th>Art</th><th>Ort</th><th>Benutzer</th><th>Status</th></tr></thead><tbody>
{list.map((c) => <tr key={c.id}><td className="mono">{c.customerNumber}</td><td><Link href={`/admin/kunden/${c.id}`}>{c.name}</Link></td><td>{c.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</td><td>{c.city ?? '–'}</td><td>{c.members}</td><td><Status value={c.status} /></td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,70 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, fmt } from '@/components/ui';
interface Cust { externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
imported: { orgId: string; customerNumber: string } | null; resourceKnown: boolean; resourceOrgId: string | null; suggestedProducts: { id: string; sku: string; name: string; status: string }[]; possibleOrgs: { id: string; customerNumber: string; name: string }[] }
interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string }
interface Row { sel: boolean; type: 'private' | 'business'; productId: string; start: string; link: string }
interface Result { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }
export default function KundenUebernahme() {
const { can } = useSession();
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [list, setList] = useState<Cust[] | null>(null); const [err, setErr] = useState('');
const [rows, setRows] = useState<Record<string, Row>>({}); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [results, setResults] = useState<Result[] | null>(null);
useEffect(() => { api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('customers.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setList([]); }).catch((e) => setErr(errMsg(e))); }, []);
const load = useCallback(() => {
if (!connId) return; setList(null); setErr(''); setResults(null);
api<Cust[]>('GET', `/admin/connectors/${connId}/customers`).then((l) => {
setList(l);
setRows(Object.fromEntries(l.map((c) => [c.externalRef, { sel: false, type: c.company ? 'business' : 'private', productId: c.suggestedProducts[0]?.id ?? '', start: (c.createdAt ?? new Date().toISOString()).slice(0, 10), link: '' } as Row])));
}).catch((e) => { setErr(errMsg(e)); setList([]); });
}, [connId]);
useEffect(() => { load(); }, [load]);
const set = (ref: string, p: Partial<Row>) => setRows((r) => ({ ...r, [ref]: { ...r[ref]!, ...p } }));
const visible = useMemo(() => (list ?? []).filter((c) => !q || [c.displayName, c.email, c.planName].some((x) => x?.toLowerCase().includes(q.toLowerCase()))), [list, q]);
const open = visible.filter((c) => !c.imported && (!c.resourceOrgId));
const chosen = (list ?? []).filter((c) => rows[c.externalRef]?.sel);
if (!can('customers.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
async function run() {
setBusy(true); setErr('');
try {
const items = chosen.map((c) => { const r = rows[c.externalRef]!; return { externalRef: c.externalRef, type: r.type, ...(r.link ? { linkToOrgId: r.link } : {}), ...(r.productId ? { contract: { productId: r.productId, startedAt: new Date(r.start).toISOString() } } : {}) }; });
const r = await api<{ results: Result[] }>('POST', `/admin/connectors/${connId}/customers/import`, { items }); setResults(r.results); load();
} catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<h1>Kunden übernehmen</h1>
<p className="muted">Die Kunden werden live aus der Verbindung gelesen. Beim Anbieter wird nichts verändert, und es werden <strong>keine E-Mails</strong> an die Kunden versendet. Die Einladung zum Kundencenter senden Sie später gezielt.</p>
{err && <Alert kind="err">{err} <button className="btn small" onClick={load}>Erneut versuchen</button></Alert>}
<div className="card"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}{c.health !== 'ok' ? ' (gestört)' : ''}</option>)}</select></Field></div>
{results && <div className="card"><h2>Ergebnis</h2>
<div className="tablewrap"><table><thead><tr><th>Konto</th><th>Ergebnis</th><th>Kunde</th><th>Vertrag</th></tr></thead><tbody>{results.map((r) => { const c = list?.find((x) => x.externalRef === r.externalRef); return <tr key={r.externalRef}><td>{c?.displayName ?? r.externalRef}</td>
<td><span className={`badge ${r.status === 'skipped' ? 'warn' : 'ok'}`}><span aria-hidden="true">{r.status === 'skipped' ? '▲' : '✓'}</span>{r.status === 'created' ? 'Angelegt' : r.status === 'linked' ? 'Verknüpft' : 'Übersprungen'}</span>{r.reason && <div className="small muted">{r.reason}</div>}</td>
<td>{r.orgId ? <Link href={`/admin/kunden/${r.orgId}`}>{r.customerNumber}</Link> : '–'}</td><td className="mono">{r.contractNumber ?? '–'}</td></tr>; })}</tbody></table></div>
<p className="small muted" style={{ marginTop: 12 }}>Die Inhaber wurden als „eingeladen“ angelegt. Zum Zugang senden Sie die Einladung auf der Kundenseite unter „Benutzer“.</p></div>}
{conns.length === 0 && list !== null && <div className="card"><Empty title="Keine geeignete Verbindung">Legen Sie unter Einstellungen → Verbindungen eine Verbindung an, die Kunden bereitstellt (z. B. KeyHelp).</Empty></div>}
{list === null ? <p className="muted" role="status">Kunden werden beim Anbieter gelesen …</p> : list.length > 0 && (
<div className="card"><div className="row between"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, E-Mail oder Tarif" /></Field>
<div className="row"><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: open.some((c) => c.externalRef === k) ? true : v.sel }])))}>Alle noch nicht übernommenen wählen</button><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: false }])))}>Auswahl aufheben</button></div></div>
<div className="tablewrap"><table><thead><tr><th><span className="sr">Auswahl</span></th><th>Konto</th><th>Tarif</th><th>Art</th><th>Vertrag (Bestand)</th><th>Verknüpfen mit</th></tr></thead><tbody>
{visible.map((c) => { const r = rows[c.externalRef]; if (!r) return null; const done = !!c.imported || !!c.resourceOrgId; return (<tr key={c.externalRef}>
<td><input type="checkbox" style={{ width: 20, minHeight: 20 }} aria-label={`${c.displayName} auswählen`} checked={r.sel} disabled={done} onChange={(e) => set(c.externalRef, { sel: e.target.checked })} /></td>
<td>{c.displayName}<div className="small muted">{c.email ?? 'keine E-Mail'} · seit {c.createdAt ? fmt(c.createdAt).split(',')[0] : '–'}{c.state === 'suspended' && <> · <span className="badge lock"><span aria-hidden="true">⊘</span>gesperrt</span></>}</div>
{c.imported && <div className="small"><span className="badge ok"><span aria-hidden="true">✓</span>Übernommen</span> <Link href={`/admin/kunden/${c.imported.orgId}`}>{c.imported.customerNumber}</Link></div>}
{!c.imported && c.resourceOrgId && <div className="small"><span className="badge info"><span aria-hidden="true">○</span>Konto bereits zugeordnet</span></div>}</td>
<td>{c.planName ?? '–'}</td>
<td><select aria-label="Kundenart" value={r.type} disabled={done} onChange={(e) => set(c.externalRef, { type: e.target.value as Row['type'] })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="business">Geschäftskunde</option><option value="private">Privatkunde</option></select></td>
<td><select aria-label="Produkt für den Bestandsvertrag" value={r.productId} disabled={done} onChange={(e) => set(c.externalRef, { productId: e.target.value })} style={{ minHeight: 36, padding: '4px 8px', maxWidth: 220 }}><option value="">Keinen Vertrag anlegen</option>{c.suggestedProducts.map((p) => <option key={p.id} value={p.id}>{p.name}{p.status !== 'active' ? ' (Entwurf)' : ''}</option>)}</select>
{r.productId && <div style={{ marginTop: 6 }}><label className="small muted" htmlFor={`st-${c.externalRef}`}>Vertragsbeginn</label><input id={`st-${c.externalRef}`} type="date" value={r.start} onChange={(e) => set(c.externalRef, { start: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }} /></div>}
{c.suggestedProducts.length === 0 && <div className="small muted">Kein Produkt zum Tarif vorhanden (erst Tarif als Produkt übernehmen).</div>}</td>
<td>{c.possibleOrgs.length > 0 ? <select aria-label="Mit vorhandenem Kunden verknüpfen" value={r.link} disabled={done} onChange={(e) => set(c.externalRef, { link: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="">Neuen Kunden anlegen</option>{c.possibleOrgs.map((o) => <option key={o.id} value={o.id}>{o.customerNumber} · {o.name}</option>)}</select> : <span className="muted small">Neuer Kunde</span>}</td></tr>); })}</tbody></table></div>
<div className="row" style={{ marginTop: 16 }}><button className="btn primary" disabled={busy || chosen.length === 0} onClick={run}>{busy ? 'Bitte warten …' : `${chosen.length} Kunde(n) übernehmen`}</button>
<span className="small muted">Bestandsverträge übernehmen Preis und Bedingungen des gewählten Produkts. Abrechnung folgt mit dem Rechnungsmodul.</span></div></div>)}
</>);
}

View file

@ -0,0 +1,50 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
import type { Prod } from '../page';
type D = Prod & { versions: NonNullable<Prod['current']>[] };
export default function Produkt() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
const [d, setD] = useState<D | null>(null); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [open, setOpen] = useState(false);
const load = useCallback(() => api<D>('GET', `/admin/products/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); }, [load]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const cur = d.current!;
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function newVersion(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning });
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
<div className="row between"><h1>{cur.name}</h1><Status value={d.status === 'active' ? 'active' : d.status === 'draft' ? 'invited' : 'closed'} /></div><p className="muted mono">{d.sku}</p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
{open && <form className="card" onSubmit={newVersion}><h2>Neue Version</h2><p className="small muted">Preisänderungen gelten nur für neue Bestellungen. Laufzeit und Bereitstellung bleiben unverändert.</p>
<div className="cols"><Field id="name" label="Bezeichnung"><input id="name" name="name" defaultValue={cur.name} required /></Field>
<Field id="setup" label={`Einrichtung (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={cur.setupCents / 100} /></Field>
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.requiresApproval} onChange={(e) => patch({ requiresApproval: e.target.checked }, 'Gespeichert.')} />Manuelle Freigabe erforderlich</label></div>
<fieldset style={{ border: 0, padding: 0, margin: '16px 0 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden selbst ausführen dürfen</legend><div className="row">
{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.customerActions.includes(a)} onChange={(e) => patch({ customerActions: e.target.checked ? [...d.customerActions, a] : d.customerActions.filter((x) => x !== a) }, 'Gespeichert. Gilt für neu bereitgestellte Ressourcen.')} />{ACTION_LABEL[a]}</label>)}</div></fieldset></div>
<div className="card"><h2>Versionen</h2><div className="tablewrap"><table><thead><tr><th>Version</th><th>Bezeichnung</th><th>Einrichtung</th><th>Laufend</th><th>Steuer</th></tr></thead><tbody>
{d.versions.map((v) => <tr key={v.id}><td>{v.version}{v.id === cur.id && ' (aktuell)'}</td><td>{v.name}</td><td>{eur(v.setupCents)}</td><td>{v.billingInterval === 'once' ? '–' : `${INTERVAL[v.billingInterval]} ${eur(v.recurringCents)}`}</td><td>{v.taxBp / 100} %</td></tr>)}</tbody></table></div></div>
</>);
}

View file

@ -0,0 +1,63 @@
'use client';
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, eur } from '@/components/ui';
interface Margin { type: 'percent' | 'fixed' | null; value: number | null }
interface Tld { tld: string; termMonths: number; costGrossCents: number; costNetCents: number; setupGrossCents: number; priceGrossCents: number | null; priceNetCents: number | null; profitNetCents: number | null; margin: Margin; active: boolean }
interface Data { settings: { tier: number; margin: Margin; rounding: boolean; basis: 'gross' | 'net'; taxBp: number }; tlds: Tld[] }
const TIERS = ['0–49 Domains', '50–199 Domains', '200–999 Domains', 'ab 1000 Domains'];
const showMargin = (m: Margin) => m.type === 'percent' ? `${(m.value! / 100).toLocaleString('de-DE')} %` : m.type === 'fixed' ? eur(m.value!) : null;
/** Eingabe (Prozent bzw. Euro, deutsches Komma) → Basispunkte bzw. Cent. */
const toMargin = (type: string, raw: string): Margin => { if (!type || !raw.trim()) return { type: null, value: null }; const n = Number(raw.replace(',', '.')); if (!Number.isFinite(n) || n < 0) throw new Error('Bitte einen gültigen Aufschlag eingeben.'); return { type: type as 'percent' | 'fixed', value: Math.round(n * 100) }; };
function MarginInputs({ idp, def }: { idp: string; def: Margin }) {
return (<div className="row" style={{ gap: 8 }}>
<select id={`${idp}t`} name="mtype" defaultValue={def.type ?? ''} aria-label="Art des Aufschlags"><option value="">{idp === 'g' ? 'Kein Aufschlag' : 'Standard verwenden'}</option><option value="percent">Prozent vom Einkauf</option><option value="fixed">Festbetrag in €</option></select>
<input id={`${idp}v`} name="mvalue" inputMode="decimal" style={{ width: 110 }} aria-label="Höhe des Aufschlags" defaultValue={def.value === null ? '' : String(def.value / 100).replace('.', ',')} placeholder="z. B. 25" /></div>);
}
export default function DomainPreise() {
const { can } = useSession(); const w = can('domains.write');
const [d, setD] = useState<Data | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [q, setQ] = useState(''); const [edit, setEdit] = useState<string | null>(null); const [imp, setImp] = useState(false);
const load = useCallback(() => api<Data>('GET', '/admin/domain-tlds').then(setD).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
const rows = useMemo(() => (d?.tlds ?? []).filter((t) => !q || t.tld.includes(q.trim().toLowerCase().replace(/^\./, ''))), [d, q]);
async function saveSettings(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk('');
try { await api('PUT', '/admin/domain-settings', { tier: Number(f.get('tier')), rounding: f.get('rounding') === 'on', basis: String(f.get('basis')), margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setOk('Einstellungen gespeichert. Alle Verkaufspreise wurden neu berechnet.'); void load(); } catch (x) { setErr(errMsg(x)); }
}
async function saveTld(e: FormEvent<HTMLFormElement>, tld: string) {
e.preventDefault(); const f = new FormData(e.currentTarget); setErr(''); setOk('');
try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(tld)}`, { margin: toMargin(String(f.get('mtype')), String(f.get('mvalue'))) }); setEdit(null); void load(); } catch (x) { setErr(errMsg(x)); }
}
async function toggle(t: Tld) { try { await api('PATCH', `/admin/domain-tlds/${encodeURIComponent(t.tld)}`, { active: !t.active }); void load(); } catch (x) { setErr(errMsg(x)); } }
async function doImport(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk('');
try { const r = await api<{ rows: number; created: number; updated: number; skipped: string[] }>('POST', '/admin/domain-tlds/import', { text: String(new FormData(e.currentTarget).get('text')) }); setOk(`${r.rows} Endungen eingelesen (${r.created} neu, ${r.updated} aktualisiert${r.skipped.length ? `, ${r.skipped.length} Zeilen übersprungen` : ''}). Aufschläge bleiben erhalten.`); setImp(false); void load(); } catch (x) { setErr(errMsg(x)); }
}
const noMargin = d && !d.settings.margin.type;
return (<>
<div className="row between"><h1>Domains</h1><div className="row"><Link className="btn" href="/admin/domains">Domain-Aufstellung</Link><Link className="btn" href="/admin/produkte">← Produkte</Link></div></div>
<p className="muted">Domainpreise setzen sich zusammen aus <strong>Einkaufspreis + Ihr Gewinnaufschlag</strong>. Die Preisliste von KCS gilt als <strong>Bruttopreis (inkl. USt)</strong>. Darauf wird keine USt mehr aufgeschlagen; der Nettobetrag wird herausgerechnet. Im Angebot erscheint das Hostingprodukt plus die Domain mit dem Verkaufspreis.</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{noMargin && <Alert kind="warn">Es ist noch kein Standard-Aufschlag festgelegt. Solange fehlt bei Domains ohne eigenen Aufschlag der Verkaufspreis („Preis auf Anfrage“).</Alert>}
{d && <form className="card" onSubmit={saveSettings}><h2>Preisstaffel und Standard-Aufschlag</h2>
<div className="cols"><Field id="basis" label="Preise der Einkaufsliste" hint="Bei KCS sind es Bruttopreise."><select id="basis" name="basis" defaultValue={d.settings.basis} disabled={!w}><option value="gross">Brutto (inkl. USt)</option><option value="net">Netto (zzgl. USt)</option></select></Field><Field id="tier" label="Einkaufsstaffel" hint="Welche Staffel Ihrer Einkaufsliste gilt für Sie (nach Anzahl Ihrer Domains beim Anbieter)."><select id="tier" name="tier" defaultValue={d.settings.tier} disabled={!w}>{TIERS.map((t, i) => <option key={t} value={i + 1}>{t}</option>)}</select></Field>
<div><label htmlFor="gt" style={{ fontWeight: 600 }}>Standard-Aufschlag</label><MarginInputs idp="g" def={d.settings.margin} /><p className="muted small">Gilt für alle Endungen ohne eigenen Aufschlag. Prozent in ganzen oder halben Schritten, z. B. 25 oder 12,5.</p></div></div>
<label className="row" style={{ marginBottom: 12 }}><input type="checkbox" name="rounding" style={{ width: 20, minHeight: 20 }} defaultChecked={d.settings.rounding} disabled={!w} /> Verkaufspreise „gerade“ runden (brutto): ,00–,50 auf ,50 und ,51–,98 auf ,99 aufrunden (z. B. 12,00 → 12,50; 12,60 → 12,99)</label>
{w && <button className="btn primary" type="submit">Speichern</button>}</form>}
<div className="card"><div className="row between"><Field id="q" label="Endung suchen"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="z. B. de oder shop" /></Field>
{w && <button className="btn" onClick={() => setImp(!imp)} aria-expanded={imp}>Preisliste einlesen</button>}</div>
{imp && <form onSubmit={doImport}><Field id="text" label="Preisliste einfügen" hint="Zeilen: Endung, Laufzeit, vier Staffelpreise, optional Setup (Tabulator- oder Leerzeichen-getrennt, deutsches Zahlenformat möglich). Bestehende Aufschläge bleiben erhalten."><textarea id="text" name="text" rows={8} required style={{ width: '100%', fontFamily: 'monospace' }} /></Field><button className="btn primary" type="submit">Einlesen</button></form>}
{!d ? <p className="muted" role="status">Wird geladen …</p> : <div className="tablewrap"><table><thead><tr><th>Endung</th><th>Laufzeit</th><th>Einkauf brutto</th><th>Einkauf netto</th><th>Aufschlag</th><th>Verkauf brutto</th><th>Verkauf netto</th><th>Gewinn netto</th><th>Setup brutto</th><th>Angebot</th>{w && <th></th>}</tr></thead><tbody>
{rows.slice(0, q ? 500 : 150).map((t) => (<tr key={t.tld} style={t.active ? undefined : { opacity: .55 }}><td className="mono">.{t.tld}</td><td>{t.termMonths} Mon.</td><td>{eur(t.costGrossCents)}</td><td>{eur(t.costNetCents)}</td>
<td>{edit === t.tld ? <form onSubmit={(e) => saveTld(e, t.tld)}><MarginInputs idp={`e-${t.tld}`} def={t.margin} /><div className="row"><button className="btn small primary" type="submit">OK</button><button className="btn small" type="button" onClick={() => setEdit(null)}>Abbrechen</button></div></form> : showMargin(t.margin) ?? <span className="muted small">Standard</span>}</td>
<td>{t.priceGrossCents === null ? <span className="muted small">fehlt</span> : <strong>{eur(t.priceGrossCents)}</strong>}</td><td>{t.priceNetCents === null ? '–' : eur(t.priceNetCents)}</td><td>{t.profitNetCents === null ? '–' : eur(t.profitNetCents)}</td><td>{t.setupGrossCents ? eur(t.setupGrossCents) : '–'}</td>
<td>{w ? <label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={t.active} onChange={() => toggle(t)} aria-label={`.${t.tld} anbieten`} /> {t.active ? 'ja' : 'nein'}</label> : t.active ? 'ja' : 'nein'}</td>
{w && <td>{edit !== t.tld && <button className="btn small" onClick={() => setEdit(t.tld)}>Aufschlag</button>}</td>}</tr>))}</tbody></table></div>}
{d && !q && rows.length > 150 && <p className="muted small">Die ersten 150 von {rows.length} Endungen. Mit der Suche finden Sie alle anderen.</p>}
<p className="muted small">Die Einrichtungsgebühr (Setup) wird ohne Aufschlag weitergegeben. Die Rundung (,50/,99) gilt für den Verkaufspreis brutto.</p></div>
</>);
}

View file

@ -0,0 +1,63 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/components/ui';
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
export default function HostingTarif() {
const { can } = useSession();
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]);
const [interval, setInterval_] = useState('monthly'); const [basis, setBasis] = useState<'net' | 'gross'>('gross'); const [unl, setUnl] = useState<Record<string, boolean>>({});
const [err, setErr] = useState(''); const [ok, setOk] = useState<{ id: string; name: string; features: string[] } | null>(null); const [busy, setBusy] = useState(false);
useEffect(() => { api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('catalog.write')); setConns(c); setConnId(c[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); }, []);
if (!can('products.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(null); setBusy(true); const f = new FormData(e.currentTarget); const g = (k: string) => (f.get(k) as string) ?? '';
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
const once = interval === 'once';
try {
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
plan: { name: g('planName'), limits, permissions },
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } });
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
<h1>Neuen Hosting-Tarif anlegen</h1>
<Alert kind="warn"><strong>Der Tarif wird sofort beim Anbieter (KeyHelp) angelegt.</strong> Größenangaben werden in Byte übermittelt; die Einheit ist in der API-Definition nicht angegeben. Bitte prüfen Sie den neuen Tarif einmal in KeyHelp nach, bevor Sie das Produkt aktivieren.</Alert>
{err && <Alert kind="err">{err}</Alert>}
{ok && <Alert kind="ok">Tarif „{ok.name}“ und Produkt wurden angelegt ({ok.features.join(' · ')}). <Link href={`/admin/produkte/${ok.id}`}>Produkt öffnen</Link></Alert>}
{conns.length === 0 ? <div className="card"><p className="muted">Keine Verbindung, die Tarife anlegen kann (z. B. KeyHelp mit API-Schlüssel).</p></div> : (
<form className="card" onSubmit={submit}>
<div className="cols"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<Field id="planName" label="Name des Tarifs in KeyHelp"><input id="planName" name="planName" required maxLength={100} /></Field></div>
<h3>Ressourcen des Tarifs</h3>
<div className="cols">{LIMITS.map(([k, label, ph]) => <div key={k}><Field id={k} label={label}><input id={k} name={k} type="number" min={0} step="1" placeholder={ph} disabled={!!unl[k]} required={!unl[k]} /></Field>
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!unl[k]} onChange={(e) => setUnl({ ...unl, [k]: e.target.checked })} />unbegrenzt</label></div>)}</div>
<p className="small muted">„Unbegrenzt“ wird nur angenommen, wenn die Instanz es bereits kennt; sonst meldet der Server das und Sie tragen eine Zahl ein.</p>
<fieldset style={{ border: 0, padding: 0, margin: '16px 0' }}><legend style={{ fontWeight: 600, marginBottom: 8 }}>Rechte der Kunden im Panel</legend><div className="row">{PERMS.map(([k, l]) => <label key={k} className="row small"><input type="checkbox" name={`perm-${k}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel_access', 'ftp', 'php', 'file_manager', 'backup', 'certificate_management', 'domain_email'].includes(k)} />{l}</label>)}</div></fieldset>
<h3>Produkt im Kundencenter</h3>
<div className="cols"><Field id="sku" label="Artikelnummer"><input id="sku" name="sku" required pattern="[A-Za-z0-9._\-]{2,50}" /></Field><Field id="name" label="Bezeichnung für Kunden" hint="Leer = Name des Tarifs"><input id="name" name="name" /></Field></div>
<Field id="desc" label="Beschreibung"><input id="desc" name="desc" maxLength={2000} /></Field>
<div className="cols"><Field id="basis" label="Preisangabe"><select id="basis" value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="gross">Brutto (Endkundenpreis)</option><option value="net">Netto</option></select></Field>
<Field id="int" label="Abrechnung"><select id="int" value={interval} onChange={(e) => setInterval_(e.target.value)}>{Object.entries(INTERVAL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="setup" label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={0} /></Field>
{interval !== 'once' && <Field id="recurring" label={`Preis ${INTERVAL[interval]} (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" required /></Field>}
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
<h3>Verkauf</h3>
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Tarif anlegen und Produkt erstellen'}</button></div>
</form>)}
</>);
}

View file

@ -0,0 +1,61 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null }
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
export default function Produkte() {
const { can } = useSession(); const w = can('products.write');
const [list, setList] = useState<Prod[] | null>(null); const [conns, setConns] = useState<{ id: string; name: string; connector: string }[]>([]); const [tax, setTax] = useState<{ id: string; name: string; rateBp: number }[]>([]);
const [basis, setBasis] = useState<'net' | 'gross'>('net'); const [open, setOpen] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [interval, setInterval_] = useState('yearly'); const [connId, setConnId] = useState('');
const load = useCallback(() => api<Prod[]>('GET', '/admin/products').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
useEffect(() => { void load(); api<typeof tax>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined); api<typeof conns>('GET', '/admin/connectors').then(setConns).catch(() => undefined); }, [load]);
const conn = conns.find((c) => c.id === connId);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const n = (k: string) => Math.round(Number(f.get(k) || 0));
let provisioning: Record<string, unknown> = {};
if (conn?.connector === 'licensing') provisioning = { programId: n('programId'), durationType: f.get('durationType'), userLimit: f.get('userLimit') ? n('userLimit') : null };
const recurring = interval === 'once' ? 0 : Math.round(Number(f.get('recurring') || 0) * 100);
try {
await api('POST', '/admin/products', { sku: f.get('sku'), category: f.get('category'), connectorInstanceId: connId || null, orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', status: f.get('activate') === 'on' ? 'active' : 'draft',
customerActions: [...CUSTOMER_ACTIONS].filter((a) => f.get(`act-${a}`) === 'on'),
version: { name: f.get('name'), description: (f.get('description') as string) || undefined, taxRateId: f.get('tax'), priceBasis: basis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: recurring, billingInterval: interval, termMonths: interval === 'once' ? 0 : n('term'), renewal: interval !== 'once' && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: n('notice') || 30, provisioning } });
setOpen(false); setMsg({ k: 'ok', t: 'Produkt angelegt.' }); void load();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
<div className="row between"><h1>Produkte</h1>{w && <div className="row"><Link className="btn primary" href="/admin/produkte/uebernahme">Aus Verbindung übernehmen</Link><Link className="btn" href="/admin/produkte/domains">Domains (Preisliste)</Link><Link className="btn" href="/admin/produkte/hosting-tarif">Neuen Hosting-Tarif anlegen</Link><button className="btn" aria-expanded={open} onClick={() => setOpen(!open)}>Manuell anlegen</button></div>}</div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neues Produkt</h2>
<div className="cols"><Field id="sku" label="Artikelnummer"><input id="sku" name="sku" required pattern="[A-Za-z0-9._\-]{2,50}" /></Field><Field id="name" label="Bezeichnung"><input id="name" name="name" required /></Field>
<Field id="category" label="Kategorie"><select id="category" name="category">{Object.entries(CAT).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
<Field id="description" label="Beschreibung (für Kunden sichtbar)"><input id="description" name="description" maxLength={2000} /></Field>
<h3>Preis</h3>
<div className="cols"><Field id="basis" label="Preisangabe"><select id="basis" value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="net">Netto</option><option value="gross">Brutto (bleibt exakt)</option></select></Field><Field id="interval" label="Abrechnung"><select id="interval" value={interval} onChange={(e) => setInterval_(e.target.value)}><option value="once">Einmalig</option><option value="monthly">Monatlich</option><option value="yearly">Jährlich</option></select></Field>
<Field id="setup" label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="setup" name="setup" type="number" min={0} step="0.01" defaultValue={0} /></Field>
{interval !== 'once' && <Field id="recurring" label={`Preis ${INTERVAL[interval]} (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={0} /></Field>}
<Field id="tax" label="Steuersatz"><select id="tax" name="tax">{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field></div>
{interval !== 'once' && <><h3>Laufzeit</h3><div className="cols"><Field id="term" label="Mindestlaufzeit (Monate)" hint="0 = keine"><input id="term" name="term" type="number" min={0} defaultValue={interval === 'yearly' ? 12 : 0} /></Field>
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field>
<label className="row" style={{ alignSelf: 'end', minHeight: 44 }}><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label></div></>}
<h3>Bereitstellung</h3>
<Field id="conn" label="Verbindung (leer = manuell, ohne Anbieter)"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}><option value="">— keine —</option>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
{conn?.connector === 'licensing' && <div className="cols"><Field id="programId" label="Programm-ID im Lizenzsystem"><input id="programId" name="programId" type="number" min={1} required /></Field>
<Field id="durationType" label="Lizenzdauer"><select id="durationType" name="durationType"><option value="YEAR">Jahr</option><option value="MONTH">Monat</option><option value="WEEK">Woche</option><option value="UNLIMITED">Unbegrenzt</option></select></Field>
<Field id="userLimit" label="Benutzerlimit (leer = unbegrenzt)"><input id="userLimit" name="userLimit" type="number" min={1} /></Field></div>}
<h3>Verkauf</h3>
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
<fieldset style={{ border: 0, padding: 0, margin: '16px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden an der Ressource selbst ausführen dürfen</legend>
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
<div className="row"><button className="btn primary" type="submit">Produkt anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card">{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Noch keine Produkte">Übernehmen Sie Produkte aus einer Verbindung oder legen Sie sie manuell an.</Empty> :
<div className="tablewrap"><table><thead><tr><th>Artikel</th><th>Bezeichnung</th><th>Preis</th><th>Bereitstellung</th><th>Status</th></tr></thead><tbody>
{list.map((p) => <tr key={p.id}><td className="mono">{p.sku}</td><td><Link href={`/admin/produkte/${p.id}`}>{p.current?.name}</Link><div className="small muted">{CAT[p.category]}{p.orderableByCustomer ? ' · Selbstbestellung' : ''}</div></td>
<td className="small">{p.current && <>{p.current.setupCents > 0 && <div>Einrichtung {eur(p.current.setupCents)} {p.current.priceBasis === 'gross' ? 'brutto' : 'netto'}</div>}<div>{p.current.billingInterval === 'once' ? 'einmalig' : `${INTERVAL[p.current.billingInterval]} ${eur(p.current.recurringCents)} ${p.current.priceBasis === 'gross' ? 'brutto' : 'netto'}`}</div></>}</td>
<td>{p.connectorName ?? 'manuell'}</td><td><Status value={PSTATUS[p.status] ?? p.status} /></td></tr>)}</tbody></table></div>}</div></>);
}

View file

@ -0,0 +1,149 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
interface Item { fixed?: boolean; externalRef: string; name: string; description: string | null; category: string; group?: string; program?: string; features?: string[]; badge?: string | null; providerActive?: boolean; price?: { cents: number; currency: string }; interval?: 'once' | 'monthly' | 'yearly'; meta?: Record<string, string | number | null>; provisioning: Record<string, unknown>; hints?: { label: string; provisioning: Record<string, unknown>; count: number }[]; importedProducts: number }
interface BundleProduct { sku: string; name: string; description?: string; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; taxBp: number; interval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; provisioning: Record<string, unknown>; exists: boolean }
interface Bundle { key: string; name: string; description?: string; source?: string; products: BundleProduct[] }
interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string }
interface Tax { id: string; name: string; rateBp: number }
const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
const DUR: Record<string, string> = { YEAR: 'Jahr', MONTH: 'Monat', WEEK: 'Woche', UNLIMITED: 'Unbegrenzt' };
const slug = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '').toUpperCase().slice(0, 30);
/** Passende Abrechnung zur Lizenzdauer vorschlagen (änderbar). */
const suggest = (d: string) => (d === 'YEAR' ? { interval: 'yearly', term: 12 } : d === 'MONTH' ? { interval: 'monthly', term: 0 } : { interval: 'once', term: 0 });
function ImportForm({ conn, item, tax, onDone, onCancel }: { conn: Conn; item: Item; tax: Tax[]; onDone: (id: string, sku: string) => void; onCancel: () => void }) {
const lic = conn.connector === 'licensing';
const first = (item.hints?.[0]?.provisioning ?? item.provisioning) as { durationType?: string; userLimit?: number | null };
const fixed = lic && !!item.provisioning.productId; // Produkt des Lizenzsystems legt Dauer und Limit fest
const providerFixed = !lic && item.fixed === true; // z. B. Hosting-Tarif: Provisionierung vom Anbieter vorgegeben
const [dur, setDur] = useState(first.durationType ?? 'YEAR'); const [limit, setLimit] = useState<string>(first.userLimit == null ? '' : String(first.userLimit));
const [interval, setInterval_] = useState<string>(item.interval ?? suggest(first.durationType ?? 'YEAR').interval); const [term, setTerm] = useState(suggest(first.durationType ?? 'YEAR').term);
const [basis, setBasis] = useState<'net' | 'gross'>(item.price ? 'gross' : 'net');
const price = item.price ? (item.price.cents / 100).toFixed(2) : '0';
const descDefault = [item.description, item.features?.length ? `Enthalten: ${item.features.join(' · ')}` : ''].filter(Boolean).join(' – ');
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const [variant] = useState(item.importedProducts);
const pick = (h: NonNullable<Item['hints']>[number]) => { const p = h.provisioning as { durationType?: string; userLimit?: number | null }; const d = p.durationType ?? 'YEAR'; setDur(d); setLimit(p.userLimit == null ? '' : String(p.userLimit)); const s = suggest(d); setInterval_(s.interval); setTerm(s.term); };
const changeDur = (d: string) => { setDur(d); const s = suggest(d); setInterval_(s.interval); setTerm(s.term); };
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = new FormData(e.currentTarget);
const provisioning = lic ? { programId: Number(item.provisioning.programId), ...(fixed ? { productId: Number(item.provisioning.productId) } : {}), durationType: dur, userLimit: limit ? Number(limit) : null } : item.provisioning;
const sku = f.get('sku') as string;
try {
const r = await api<{ id: string }>('POST', '/admin/products', { sku, category: f.get('category'), connectorInstanceId: conn.id, externalRef: item.externalRef,
orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', status: f.get('activate') === 'on' ? 'active' : 'draft',
customerActions: [...CUSTOMER_ACTIONS].filter((a) => f.get(`act-${a}`) === 'on'),
version: { name: f.get('name'), description: (f.get('description') as string) || undefined, taxRateId: f.get('tax'), priceBasis: basis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: interval === 'once' ? 0 : Math.round(Number(f.get('recurring') || 0) * 100),
billingInterval: interval, termMonths: interval === 'once' ? 0 : Number(f.get('term') || 0), renewal: interval !== 'once' && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(f.get('notice') || 30), provisioning } });
onDone(r.id, sku);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
const id = (n: string) => `${item.externalRef}-${n}`;
return (
<form onSubmit={submit} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
{err && <Alert kind="err">{err}</Alert>}
<div className="cols"><Field id={id('name')} label="Bezeichnung für Kunden"><input id={id('name')} name="name" defaultValue={item.name} required /></Field>
<Field id={id('sku')} label="Artikelnummer"><input id={id('sku')} name="sku" defaultValue={`${slug(conn.connector === 'licensing' ? 'LIC' : conn.connector)}-${slug(item.name)}${variant ? `-${variant + 1}` : ''}`} required pattern="[A-Za-z0-9._\-]{2,50}" /></Field>
<Field id={id('cat')} label="Kategorie"><select id={id('cat')} name="category" defaultValue={item.category}>{Object.entries(CAT).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
<Field id={id('desc')} label="Beschreibung (für Kunden sichtbar)"><input id={id('desc')} name="description" defaultValue={descDefault} maxLength={2000} /></Field>
{fixed ? (<><h3>Vorgaben des Lizenzsystems</h3><p className="small muted">Diese Werte legt das Produkt im Lizenzsystem fest und werden bei der Bereitstellung übernommen.</p>
<div className="cols"><Field id={id('pid')} label="Programm-ID"><input id={id('pid')} value={String(item.provisioning.programId)} readOnly /></Field><Field id={id('prid')} label="Produkt-ID"><input id={id('prid')} value={String(item.provisioning.productId)} readOnly /></Field>
<Field id={id('dur')} label="Lizenzdauer"><input id={id('dur')} value={DUR[dur] ?? dur} readOnly /></Field><Field id={id('lim')} label="Benutzerlimit"><input id={id('lim')} value={limit || 'unbegrenzt'} readOnly /></Field></div>
{item.meta?.modules && <p className="small muted">Module: {String(item.meta.modules)}</p>}</>)
: providerFixed ? (<><h3>Vorgaben des Anbieters</h3><p className="small muted">Diese Werte legt der Tarif beim Anbieter fest und werden bei der Bereitstellung übernommen.</p>{item.features && item.features.length > 0 && <p className="small">{item.features.join(' · ')}</p>}<p className="small muted">Tarif-ID: <span className="mono">{String(item.provisioning.hostingPlanId ?? item.externalRef)}</span></p></>)
: lic ? (<><h3>Lizenz-Vorgaben</h3>
{item.hints && item.hints.length > 0 && <div className="row" style={{ marginBottom: 12 }} role="group" aria-label="Vorschläge aus bestehenden Lizenzen"><span className="small muted">Vorschläge aus bestehenden Lizenzen:</span>{item.hints.map((h) => <button type="button" className="btn small" key={h.label} onClick={() => pick(h)}>{h.label} ({h.count}×)</button>)}</div>}
<div className="cols"><Field id={id('pid')} label="Programm-ID (vom Anbieter)"><input id={id('pid')} value={String(item.provisioning.programId)} readOnly /></Field>
<Field id={id('dur')} label="Lizenzdauer"><select id={id('dur')} value={dur} onChange={(e) => changeDur(e.target.value)}>{Object.entries(DUR).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id={id('lim')} label="Benutzerlimit" hint="Leer = unbegrenzt"><input id={id('lim')} type="number" min={1} value={limit} onChange={(e) => setLimit(e.target.value)} /></Field></div></>)
: <p className="small muted">Vorgaben des Anbieters: <code>{JSON.stringify(item.provisioning)}</code></p>}
<h3>Preis und Laufzeit</h3>
{item.price && <p className="small muted">Preis aus dem Lizenzsystem übernommen ({(item.price.cents / 100).toLocaleString('de-DE', { style: 'currency', currency: item.price.currency })}). Das Lizenzsystem gibt nicht an, ob brutto oder netto – bitte prüfen (voreingestellt: brutto, Endkundenpreis).</p>}
<div className="cols"><Field id={id('basis')} label="Preisangabe"><select id={id('basis')} value={basis} onChange={(e) => setBasis(e.target.value as 'net' | 'gross')}><option value="net">Netto (Steuer wird aufgeschlagen)</option><option value="gross">Brutto (Endkundenpreis, bleibt exakt)</option></select></Field>
<Field id={id('int')} label="Abrechnung"><select id={id('int')} value={interval} onChange={(e) => setInterval_(e.target.value)}><option value="once">Einmalig</option><option value="monthly">Monatlich</option><option value="yearly">Jährlich</option></select></Field>
<Field id={id('setup')} label={`Einrichtung (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id={id('setup')} name="setup" type="number" min={0} step="0.01" defaultValue={interval === 'once' ? price : 0} /></Field>
{interval !== 'once' && <Field id={id('rec')} label={`Preis (€ ${basis === 'gross' ? 'brutto' : 'netto'})`}><input id={id('rec')} name="recurring" type="number" min={0} step="0.01" defaultValue={price} required /></Field>}
<Field id={id('tax')} label="Steuersatz"><select id={id('tax')} name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
{interval !== 'once' && <><Field id={id('term')} label="Mindestlaufzeit (Monate)" hint="0 = keine"><input id={id('term')} name="term" type="number" min={0} value={term} onChange={(e) => setTerm(Number(e.target.value))} /></Field>
<Field id={id('notice')} label="Kündigungsfrist (Tage)"><input id={id('notice')} name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
<h3>Verkauf</h3>
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Aktionen, die Kunden an der Ressource selbst ausführen dürfen</legend>
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
<p className="small muted">Das Produkt wird als Entwurf angelegt, sofern „Sofort aktivieren“ nicht gewählt ist. Beim Anbieter wird dabei nichts verändert.</p>
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Als Produkt übernehmen'}</button><button className="btn" type="button" onClick={onCancel}>Abbrechen</button></div>
</form>
);
}
const eur = (c: number) => (c / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
const INT: Record<string, string> = { once: 'einmalig', monthly: 'monatlich', yearly: 'jährlich' };
const EDITION = (p: Record<string, unknown>) => (p.keyPrefix ? String(p.keyPrefix) : p.keyPrefix === null ? 'UNLIMITED (ohne Präfix)' : '–');
/** Vorlagenpaket (z. B. Familytool-Editionen): Vorschau, Auswahl, Import als Entwürfe. */
function BundlePanel({ conn, item, bundles, onDone, onCancel }: { conn: Conn; item: Item; bundles: Bundle[]; onDone: (msg: string) => void; onCancel: () => void }) {
const [key, setKey] = useState(bundles[0]?.key ?? ''); const b = bundles.find((x) => x.key === key);
const [sel, setSel] = useState<Record<string, boolean>>({}); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
useEffect(() => { setSel(Object.fromEntries((b?.products ?? []).map((p) => [p.sku, !p.exists]))); }, [b]);
const chosen = Object.keys(sel).filter((k) => sel[k]);
async function go() {
setBusy(true); setErr('');
try { const r = await api<{ created: { sku: string }[]; skipped: { sku: string; reason: string }[] }>('POST', `/admin/product-bundles/${key}/import`, { connectorInstanceId: conn.id, programRef: item.externalRef, skus: chosen });
onDone(`${r.created.length} Produkt(e) als Entwurf angelegt${r.skipped.length ? `, ${r.skipped.length} übersprungen (${r.skipped.map((x) => `${x.sku}: ${x.reason}`).join('; ')})` : ''}.`); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
if (!b) return <p className="muted">Keine Vorlagenpakete vorhanden.</p>;
return (<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
{err && <Alert kind="err">{err}</Alert>}
<Field id={`b-${item.externalRef}`} label="Vorlagenpaket"><select id={`b-${item.externalRef}`} value={key} onChange={(e) => setKey(e.target.value)}>{bundles.map((x) => <option key={x.key} value={x.key}>{x.name}</option>)}</select></Field>
{b.description && <p className="muted small">{b.description}</p>}
<div className="tablewrap"><table><thead><tr><th><span className="sr">Auswahl</span></th><th>Produkt</th><th>Preis</th><th>Laufzeit</th><th>Edition</th></tr></thead><tbody>
{b.products.map((p) => <tr key={p.sku}><td><input type="checkbox" style={{ width: 20, minHeight: 20 }} aria-label={`${p.name} auswählen`} checked={!!sel[p.sku]} disabled={p.exists} onChange={(e) => setSel({ ...sel, [p.sku]: e.target.checked })} /></td>
<td>{p.name}<div className="small muted mono">{p.sku}{p.exists ? ' · bereits angelegt' : ''}</div></td>
<td className="small">{p.setupCents > 0 && <div>Einrichtung {eur(p.setupCents)}</div>}{(p.interval !== 'once' || p.recurringCents > 0) && <div>{INT[p.interval]} {eur(p.recurringCents)}</div>}<div className="muted">{p.priceBasis === 'gross' ? 'brutto' : 'netto'}, {p.taxBp / 100} % USt.</div></td>
<td className="small">{p.termMonths > 0 ? `${p.termMonths} Monate` : p.interval === 'once' ? 'unbefristet' : 'rollierend'}{p.renewal === 'auto' ? ', verlängert sich' : ''}</td><td className="small">{EDITION(p.provisioning)}{p.provisioning.validityDays ? `, ${String(p.provisioning.validityDays)} Tage` : ''}</td></tr>)}</tbody></table></div>
<p className="small muted">Alle Produkte werden als <strong>Entwurf</strong> angelegt und lassen sich danach einzeln anpassen (Preis, Regeln) und aktivieren. Produkte mit Edition können erst aktiviert werden, wenn das Lizenzsystem die Edition-Erweiterung unterstützt. Beim Anbieter wird nichts verändert.</p>
<div className="row"><button className="btn primary" disabled={busy || !chosen.length} onClick={go}>{busy ? 'Bitte warten …' : `${chosen.length} Produkt(e) übernehmen`}</button><button className="btn" onClick={onCancel}>Abbrechen</button></div></div>);
}
export default function Uebernahme() {
const { can } = useSession();
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [tax, setTax] = useState<Tax[]>([]);
const [bundles, setBundles] = useState<Bundle[]>([]); const [bopen, setBopen] = useState<string | null>(null); const [items, setItems] = useState<Item[] | null>(null); const [err, setErr] = useState(''); const [open, setOpen] = useState<string | null>(null); const [done, setDone] = useState<{ id: string; sku: string } | null>(null); const [msg, setMsg] = useState('');
useEffect(() => {
api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('catalog.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setItems([]); }).catch((e) => setErr(errMsg(e)));
api<Tax[]>('GET', '/admin/tax-rates').then(setTax).catch(() => undefined);
api<Bundle[]>('GET', '/admin/product-bundles').then(setBundles).catch(() => undefined);
}, []);
const load = useCallback(() => { if (!connId) return; setItems(null); setErr(''); api<Item[]>('GET', `/admin/connectors/${connId}/catalog`).then(setItems).catch((e) => { setErr(errMsg(e)); setItems([]); }); }, [connId]);
useEffect(() => { load(); }, [load]);
if (!can('products.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
const conn = conns.find((c) => c.id === connId);
return (<>
<p><Link href="/admin/produkte">← Alle Produkte</Link></p>
<h1>Produkte übernehmen</h1>
<p className="muted">Die Angebote werden live aus der Verbindung gelesen. Sie legen hier Preis, Laufzeit, Steuer und Verkaufsregeln fest. Beim Anbieter wird nichts verändert.</p>
{err && <Alert kind="err">{err} <button className="btn small" onClick={load}>Erneut versuchen</button></Alert>}
{msg && <Alert kind="ok">{msg} <Link href="/admin/produkte">Zu den Produkten</Link></Alert>}
{done && <Alert kind="ok">Produkt <strong>{done.sku}</strong> wurde angelegt. <Link href={`/admin/produkte/${done.id}`}>Produkt öffnen</Link></Alert>}
<div className="card"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => { setConnId(e.target.value); setOpen(null); }}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}{c.health !== 'ok' ? ' (gestört)' : ''}</option>)}</select></Field></div>
{conns.length === 0 && items !== null && <div className="card"><Empty title="Keine geeignete Verbindung">Legen Sie unter „Verbindungen“ eine Verbindung an, die Produktübernahme unterstützt (z. B. das Lizenzsystem mit API-Zugang).</Empty></div>}
{conn && items === null && <p className="muted" role="status">Angebote werden beim Anbieter gelesen …</p>}
{conn && items && items.length === 0 && !err && <div className="card"><Empty title="Beim Anbieter wurden keine Angebote gefunden" /></div>}
{conn && items?.map((it) => (
<div className="card" key={it.externalRef}>
<div className="row between"><h2 style={{ margin: 0 }}>{it.name}</h2>{it.importedProducts > 0 ? <span className="badge ok"><span aria-hidden="true">✓</span>{it.importedProducts} Produkt{it.importedProducts > 1 ? 'e' : ''} angelegt</span> : <span className="badge info"><span aria-hidden="true">○</span>Noch nicht übernommen</span>}</div>
<p className="muted small">{it.program ? `${it.program} · ` : ''}{CAT[it.category]}{it.price ? ` · ${(it.price.cents / 100).toLocaleString('de-DE', { style: 'currency', currency: it.price.currency })}` : ''}{it.interval && it.price ? ` (${INT[it.interval]})` : ''} · Kennung: <span className="mono">{it.externalRef}</span>{it.description ? ` · ${it.description}` : ''}</p>
{it.features && it.features.length > 0 && <p className="small">Enthalten: {it.features.join(' · ')}</p>}
{it.providerActive === false && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Beim Anbieter inaktiv</span></p>}
{bopen === it.externalRef ? <BundlePanel conn={conn} item={it} bundles={bundles} onCancel={() => setBopen(null)} onDone={(m) => { setBopen(null); setDone(null); setMsg(m); load(); }} />
: open === it.externalRef ? <ImportForm conn={conn} item={it} tax={tax} onCancel={() => setOpen(null)} onDone={(id, sku) => { setOpen(null); setDone({ id, sku }); load(); }} />
: <div className="row"><button className="btn" onClick={() => { setOpen(it.externalRef); setDone(null); setMsg(''); }}>{it.importedProducts > 0 ? 'Weitere Variante anlegen' : 'Übernehmen'}</button>
{bundles.length > 0 && !it.provisioning.productId && <button className="btn" onClick={() => { setBopen(it.externalRef); setDone(null); setMsg(''); }}>Vorlagenpaket importieren</button>}</div>}
</div>))}
</>);
}

View file

@ -0,0 +1,36 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui';
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number }
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
export default function Bestellen() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
async function order(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
<h1>Neue Bestellung</h1>{err && <Alert kind="err">{err}</Alert>}
{staff && <div className="card"><Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setSel(null); }}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name} ({c.customerType === 'private' ? 'Privat' : 'Geschäft'})</option>)}</select></Field></div>}
{cat === null ? <p className="muted" role="status">Wird geladen …</p> : cat.length === 0 ? <div className="card"><Empty title="Keine Produkte bestellbar">Aktuell sind keine Produkte für die Selbstbestellung freigegeben.</Empty></div> : (<>
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
<div className="small"><PriceText p={p.price} priv={priv} /></div>
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => setSel(p)}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
{sel && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p><div className="small" style={{ marginBottom: 16 }}><PriceText p={sel.price} priv={priv} /></div>
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>
<button className="btn primary" disabled={busy} type="submit">{busy ? 'Bitte warten …' : 'Verbindlich bestellen'}</button></form>}
</>)}
</>);
}

View file

@ -0,0 +1,33 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, fmt, OrderStatusBadge, type Price, PriceText } from '@/components/ui';
interface O { id: string; number: string; orgName: string; status: string; createdAt: string; failureNote: string | null; failureAmbiguous: boolean; note: string | null; items: { id: string; quantity: number; contractId: string | null; snapshot: Price & { name: string; customerType: string } }[] }
export default function Bestellungen() {
const { can, me } = useSession(); const staff = can('orders.read'); const approver = can('orders.approve');
const [list, setList] = useState<O[] | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(''); const [chk, setChk] = useState<Record<string, boolean>>({});
const load = useCallback(() => api<O[]>('GET', '/orders').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
useEffect(() => { void load(); const t = setInterval(() => void load(), 5000); return () => clearInterval(t); }, [load]);
async function act(o: O, path: string, body?: unknown, ok = 'Erledigt.') { setBusy(o.id); try { await api('POST', path, body); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); } }
const canOrder = !!me && (staff ? can('orders.write') : me.organizations.some((o) => o.role !== 'member'));
return (<>
<div className="row between"><h1>Bestellungen</h1>{canOrder && <Link className="btn primary" href="/bestellen">Neue Bestellung</Link>}</div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <div className="card"><Empty title="Keine Bestellungen" /></div> : list.map((o) => (
<div className="card" key={o.id}><div className="row between"><h2 style={{ margin: 0 }}><span className="mono">{o.number}</span>{staff && <span className="muted"> · {o.orgName}</span>}</h2><OrderStatusBadge value={o.status} /></div>
<p className="small muted">Bestellt am {fmt(o.createdAt)}{o.note ? ` · „${o.note}“` : ''}</p>
{o.items.map((i) => <div key={i.id} style={{ marginBottom: 8 }}><strong>{i.quantity} × {i.snapshot.name}</strong>{i.contractId && <> · <Link href={`/vertraege/${i.contractId}`}>Vertrag</Link></>}<div className="small"><PriceText p={i.snapshot} priv={i.snapshot.customerType === 'private'} /></div></div>)}
{o.status === 'failed' && o.failureNote && <Alert kind="err">{o.failureNote}</Alert>}
{o.status === 'provisioning' && <p className="small muted" role="status">Die Bereitstellung läuft im Hintergrund. Diese Seite aktualisiert sich automatisch.</p>}
<div className="row">
{approver && o.status === 'pending_approval' && <><button className="btn primary" disabled={busy === o.id} onClick={() => act(o, `/admin/orders/${o.id}/approve`, undefined, 'Bestellung freigegeben, die Bereitstellung wurde gestartet.')}>Freigeben</button>
<button className="btn danger" disabled={busy === o.id} onClick={() => act(o, `/admin/orders/${o.id}/reject`, {}, 'Bestellung abgelehnt.')}>Ablehnen</button></>}
{approver && o.status === 'failed' && <>{o.failureAmbiguous && <label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!chk[o.id]} onChange={(e) => setChk({ ...chk, [o.id]: e.target.checked })} />Ich habe beim Anbieter geprüft, dass nichts angelegt wurde</label>}
<button className="btn" disabled={busy === o.id || (o.failureAmbiguous && !chk[o.id])} onClick={() => act(o, `/admin/orders/${o.id}/retry`, { confirmChecked: !!chk[o.id] }, 'Die Bereitstellung wurde erneut gestartet.')}>Erneut versuchen</button></>}
{(o.status === 'pending_approval' || (can('orders.write') && ['approved', 'failed'].includes(o.status))) && <button className="btn" disabled={busy === o.id} onClick={() => act(o, `/orders/${o.id}/cancel`, undefined, 'Bestellung storniert.')}>{staff ? 'Stornieren' : 'Zurückziehen'}</button>}
</div></div>))}
</>);
}

View file

@ -0,0 +1,50 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty } from '@/components/ui';
interface Sys { jobs: Record<string, number>; oldestPendingJob: string | null; backup?: { configured: boolean; lastRunAt?: string | null; ok?: boolean; ageHours?: number | null; stale?: boolean; error?: string | null; sizeBytes?: number | null; targets?: { name: string; ok: boolean }[]; lastRestoreTestAt?: string | null; restoreOk?: boolean | null; restoreStale?: boolean } }
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 [customers, setCustomers] = useState<number | null>(null); const [sys, setSys] = useState<Sys | null>(null); const [err, setErr] = useState('');
useEffect(() => {
if (me?.kind === 'customer') {
Promise.all([api<any[]>('GET', '/contracts'), api<any[]>('GET', '/resources'), api<any[]>('GET', '/orders')]).then(([contracts, resources, orders]) => setMine({ contracts, resources, orders })).catch((e) => setErr(errMsg(e)));
return;
}
if (!me || me.kind !== 'staff') return;
if (can('customers.read')) api<unknown[]>('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e)));
if (can('jobs.read')) api<Sys>('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e)));
}, [me, can]);
if (!me) return null;
const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0;
return (<>
<h1>Hallo {me.user.name.split(' ')[0]}</h1>
{err && <Alert kind="err">{err}</Alert>}
{me.kind === 'customer' ? (<>
{me.organizations.map((o) => (
<div className="card" key={o.id}><h2>{o.name}</h2><p className="muted">Kundennummer {o.customerNumber}</p><Link className="btn" href="/organisation">Organisation ansehen</Link></div>
))}
{mine && mine.contracts.length === 0 && mine.resources.length === 0 && mine.orders.length === 0 && <div className="card"><Empty title="Noch keine Produkte">Sobald Produkte, Verträge, Rechnungen oder Tickets für Ihre Organisation vorhanden sind, erscheinen sie hier.</Empty></div>}
{mine && (mine.contracts.length > 0 || mine.resources.length > 0 || mine.orders.length > 0) && <div className="grid">
<div className="card"><div className="stat">{mine.contracts.filter((c) => c.status === 'active').length}</div><p className="muted">Aktive Verträge</p>{mine.contracts.some((c) => c.cancelEffectiveAt) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigter Vertrag vorhanden</span></p>}<Link href="/vertraege">Verträge ansehen</Link></div>
<div className="card"><div className="stat">{mine.resources.length}</div><p className="muted">Ressourcen und Lizenzen</p>{mine.resources.some((r) => r.stale) && <p className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Stand teilweise veraltet</span></p>}<Link href="/ressourcen">Ressourcen ansehen</Link></div>
{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length > 0 && <div className="card"><div className="stat">{mine.orders.filter((o) => ['pending_approval', 'provisioning', 'failed'].includes(o.status)).length}</div><p className="muted">Bestellungen in Bearbeitung</p><Link href="/bestellungen">Bestellungen ansehen</Link></div>}
</div>}
</>) : (<>
<div className="grid">
{customers !== null && <div className="card"><div className="stat">{customers}</div><p className="muted">Kunden</p><Link href="/admin/kunden">Kunden verwalten</Link></div>}
{sys && <div className="card"><div className="stat">{sys.jobs.scheduled ?? 0}</div><p className="muted">Wartende Aufträge</p></div>}
{sys?.backup && <div className="card"><h3>Backup</h3><p className="small"><Link href="/einstellungen/backup">Details und Einstellungen</Link></p>
{!sys.backup.configured ? <><p className="muted">Nicht eingerichtet.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Backup</span></>
: <><p>{sys.backup.stale ? <span className="badge err"><span aria-hidden="true">✕</span>{sys.backup.ok ? 'Veraltet' : 'Fehlgeschlagen'}</span> : <span className="badge ok"><span aria-hidden="true">✓</span>Aktuell</span>}</p>
<p className="small muted">Letzter Lauf: {sys.backup.lastRunAt ? new Date(sys.backup.lastRunAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }) : '–'}{sys.backup.sizeBytes ? ` · ${(sys.backup.sizeBytes / 1024).toFixed(0)} KB` : ''}<br />Ziele: {sys.backup.targets?.length ? sys.backup.targets.map((t) => `${t.ok ? '✓' : '✕'} ${t.name}`).join(', ') : '–'}<br />Wiederherstellungstest: {sys.backup.lastRestoreTestAt ? `${sys.backup.restoreOk ? '✓' : '✕'} ${new Date(sys.backup.lastRestoreTestAt).toLocaleDateString('de-DE')}` : 'noch nie'}{sys.backup.restoreStale ? ' (überfällig)' : ''}</p>
{sys.backup.error && <p className="small" style={{ color: 'var(--err)' }}>{sys.backup.error}</p>}</>}</div>}
{sys && <div className="card"><div className="stat">{failed}</div><p className="muted">Aufträge mit Fehlern / Prüfbedarf</p>{failed > 0 && <span className="badge err"><span aria-hidden="true">✕</span>Prüfen</span>}</div>}
</div>
</>)}
</>);
}

View file

@ -0,0 +1,29 @@
'use client';
import { useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { Alert, Field, eur } from '@/components/ui';
interface Result { domain: string; tld: string; status: 'available' | 'registered' | 'unknown'; method: 'rdap' | 'dns'; note?: string; offered: boolean; offer: { termMonths: number; priceGrossCents: number; priceNetCents: number; taxBp: number; setupGrossCents: number } | null }
const LABEL = { available: { cls: 'ok', icon: '✓', text: 'Frei' }, registered: { cls: 'err', icon: '✕', text: 'Vergeben' }, unknown: { cls: 'warn', icon: '?', text: 'Unklar' } } as const;
export default function DomainPruefen() {
const [q, setQ] = useState(''); const [res, setRes] = useState<Result[] | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
async function check(e: FormEvent) {
e.preventDefault(); setErr(''); setBusy(true); setRes(null);
try { setRes((await api<{ results: Result[] }>('GET', `/domains/check?name=${encodeURIComponent(q.trim())}`)).results); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
<h1>Domain prüfen</h1>
<form className="card" onSubmit={check}>
<Field id="dq" label="Wunschdomain" hint="Mit Endung (z. B. beispiel.de) oder nur der Name – dann prüfen wir gängige Endungen."><input id="dq" value={q} onChange={(e) => setQ(e.target.value)} placeholder="beispiel.de" required autoFocus autoComplete="off" /></Field>
<button className="btn primary" type="submit" disabled={busy || !q.trim()}>{busy ? 'Wird geprüft …' : 'Prüfen'}</button>
</form>
{err && <Alert kind="err">{err}</Alert>}
{res && <div className="card" aria-live="polite"><div className="tablewrap"><table><thead><tr><th>Domain</th><th>Status</th><th>Preis</th></tr></thead><tbody>
{res.map((r) => { const l = LABEL[r.status]; return (<tr key={r.domain}><td className="mono">{r.domain}</td>
<td><span className={`badge ${l.cls}`}><span aria-hidden="true">{l.icon}</span>{l.text}</span>{r.note && <div className="muted small">{r.note}</div>}{r.status === 'unknown' && <div className="muted small">Der Status konnte nicht ermittelt werden. Bitte später erneut versuchen.</div>}</td>
<td>{!r.offered ? <span className="muted small">Endung wird nicht angeboten</span> : r.offer ? <>{eur(r.offer.priceGrossCents)} <span className="muted small">inkl. {r.offer.taxBp / 100} % USt ({eur(r.offer.priceNetCents)} netto) für {r.offer.termMonths} Monate{r.offer.setupGrossCents ? `, einmalig ${eur(r.offer.setupGrossCents)} Einrichtung` : ''}</span></> : <span className="muted small">Preis auf Anfrage</span>}</td></tr>); })}
</tbody></table></div>
<p className="muted small">Die Auskunft stammt direkt von der Registry (RDAP) oder ersatzweise aus dem DNS. Vergeben wird die Domain erst mit der Bestellung; bis dahin kann sie jemand anderes registrieren.</p></div>}
</>);
}

View file

@ -0,0 +1,79 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, fmt } from '@/components/ui';
import { BackupPassword, type EncryptionInfo } from '@/components/BackupPassword';
import { BackupTargets, type TargetInfo } from '@/components/BackupTargets';
interface Target { name: string; ok: boolean; error?: string }
interface Run { at: string; ok: boolean; file?: string; sizeBytes?: number; durationMs: number; label?: string; error?: string; targets?: Target[] }
interface Test { at: string; ok: boolean; file?: string; durationMs: number; checks: Record<string, string | number | boolean>; error?: string }
interface State { configured: boolean; running: { action: string; since: string } | null; pendingRequests: string[]; lastRun: Run | null; lastRestoreTest: Test | null; history: Run[]; stale: boolean; restoreStale: boolean;
schedule: { backup: string; restoreTest: string }; retention: { daily: number; weekly: number; monthly: number }; localDir: string; remotes: string[]; hasExternalTarget: boolean }
const LABEL: Record<string, string> = { daily: 'täglich', weekly: 'wöchentlich', monthly: 'monatlich' };
const CHECK: Record<string, string> = { pruefsumme: 'Prüfsumme der Sicherung', dumpPruefsumme: 'Prüfsumme des Datenbank-Dumps', zeilenzahlen: 'Zeilenzahlen aller Kerntabellen', migrationen: 'Datenbank-Migrationen', auditKette: 'Audit-Hash-Kette', geheimnisseOk: 'Gesicherte Geheimnisse entschlüsselbar' };
const size = (b?: number) => (b == null ? '–' : b < 1024 * 1024 ? `${Math.max(1, Math.round(b / 1024))} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`);
const dur = (ms: number) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
export default function BackupPage() {
const { can } = useSession(); const canRun = can('backup.run'); const canSecrets = can('backup.secrets');
const [cfg, setCfg] = useState<{ encryption: EncryptionInfo; targets: TargetInfo[] } | null>(null);
const loadCfg = useCallback(() => api<{ encryption: EncryptionInfo; targets: TargetInfo[] }>('GET', '/admin/backup/settings').then(setCfg).catch(() => undefined), []);
useEffect(() => { void loadCfg(); }, [loadCfg]);
const [s, setS] = useState<State | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<State>('GET', '/admin/backup').then(setS).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
const active = !!s?.running || !!s?.pendingRequests.length;
useEffect(() => { void load(); const t = setInterval(() => void load(), active ? 2500 : 20000); return () => clearInterval(t); }, [load, active]);
async function request(action: 'backup' | 'restore-test') {
setBusy(true); setMsg(null);
try { await api('POST', '/admin/backup/run', { action }); setMsg({ k: 'ok', t: action === 'backup' ? 'Die Sicherung wurde angefordert und startet in wenigen Sekunden.' : 'Der Wiederherstellungstest wurde angefordert und startet in wenigen Sekunden.' }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
if (!s) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const r = s.lastRun; const t = s.lastRestoreTest;
return (<>
<h2>Backup</h2>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{!s.hasExternalTarget && <Alert kind="warn"><strong>Kein externes Ziel eingerichtet.</strong> Die Sicherungen liegen nur lokal auf diesem Server und wären bei dessen Verlust mit verloren. Legen Sie unten unter „Externe Ziele“ ein Ziel an (SFTP, FTP, Google Drive oder Netzlaufwerk).</Alert>}
{s.running && <Alert kind="info"><span role="status">{s.running.action === 'backup' ? 'Sicherung läuft' : 'Wiederherstellungstest läuft'} seit {fmt(s.running.since)} …</span></Alert>}
{!s.running && s.pendingRequests.length > 0 && <Alert kind="info"><span role="status">Anfrage liegt vor, der Vorgang startet gleich …</span></Alert>}
<div className="grid">
<div className="card"><h3>Sicherung</h3>
{!r ? <><p className="muted">Noch nie gelaufen.</p><span className="badge warn"><span aria-hidden="true">▲</span>Keine Sicherung</span></>
: <><p>{s.stale ? <span className="badge err"><span aria-hidden="true">✕</span>{r.ok ? 'Veraltet' : 'Fehlgeschlagen'}</span> : <span className="badge ok"><span aria-hidden="true">✓</span>Aktuell</span>}</p>
<p className="small muted">Letzter Lauf: {fmt(r.at)}<br />Größe {size(r.sizeBytes)} · Dauer {dur(r.durationMs)}{r.label ? ` · ${LABEL[r.label] ?? r.label}` : ''}</p>
{r.error && <p className="small" style={{ color: 'var(--err)' }}>{r.error}</p>}</>}
<p className="small muted">Zeitplan: {s.schedule.backup}</p>
{canRun && <button className="btn primary" disabled={busy || active} onClick={() => request('backup')}>Jetzt sichern</button>}</div>
<div className="card"><h3>Wiederherstellungstest</h3>
{!t ? <><p className="muted">Noch nie gelaufen.</p><span className="badge warn"><span aria-hidden="true">▲</span>Kein Test</span></>
: <><p>{t.ok && !s.restoreStale ? <span className="badge ok"><span aria-hidden="true">✓</span>Bestanden</span> : t.ok ? <span className="badge warn"><span aria-hidden="true">▲</span>Überfällig</span> : <span className="badge err"><span aria-hidden="true">✕</span>Fehlgeschlagen</span>}</p>
<p className="small muted">Letzter Test: {fmt(t.at)} · Dauer {dur(t.durationMs)}</p>{t.error && <p className="small" style={{ color: 'var(--err)' }}>{t.error}</p>}</>}
<p className="small muted">Zeitplan: {s.schedule.restoreTest}. Die Sicherung wird in eine Wegwerf-Datenbank eingespielt, Produktivdaten bleiben unberührt.</p>
{canRun && <button className="btn" disabled={busy || active} onClick={() => request('restore-test')}>Jetzt testen</button>}</div>
</div>
{cfg && <BackupPassword info={cfg.encryption} canEdit={canSecrets} onChanged={(auto) => { void loadCfg(); if (auto) void request('backup'); }} />}
{cfg && <BackupTargets targets={cfg.targets} canEdit={canSecrets} onChanged={() => { void loadCfg(); void load(); }} />}
{t && t.checks && Object.keys(CHECK).some((k) => k in t.checks) && <div className="card"><h3>Prüfungen des letzten Tests</h3>
<ul style={{ margin: 0, paddingLeft: 20 }}>{Object.entries(CHECK).filter(([k]) => k in t.checks).map(([k, label]) => <li key={k}><span className={`badge ${t.checks[k] ? 'ok' : 'err'}`}><span aria-hidden="true">{t.checks[k] ? '✓' : '✕'}</span>{t.checks[k] ? 'OK' : 'Fehler'}</span> {label}{k === 'auditKette' && t.checks.auditEintraege ? ` (${String(t.checks.auditEintraege)} Einträge)` : ''}</li>)}</ul></div>}
<div className="card"><h3>Ergebnis je Ziel (letzter Lauf)</h3>
<div className="tablewrap"><table><thead><tr><th>Ziel</th><th>Letzter Lauf</th></tr></thead><tbody>
<tr><td>Lokal <span className="mono small muted">{s.localDir}</span></td><td>{r?.targets?.[0] ? <span className={`badge ${r.targets[0].ok ? 'ok' : 'err'}`}><span aria-hidden="true">{r.targets[0].ok ? '✓' : '✕'}</span>{r.targets[0].ok ? 'OK' : 'Fehler'}</span> : '–'}</td></tr>
{s.remotes.map((n) => { const res = r?.targets?.find((x) => x.name === n); return <tr key={n}><td className="mono">{n}</td><td>{res ? <><span className={`badge ${res.ok ? 'ok' : 'err'}`}><span aria-hidden="true">{res.ok ? '✓' : '✕'}</span>{res.ok ? 'OK' : 'Fehler'}</span>{res.error && <div className="small" style={{ color: 'var(--err)' }}>{res.error}</div>}</> : '–'}</td></tr>; })}
{s.remotes.length === 0 && <tr><td colSpan={2} className="muted small">Kein externes Ziel konfiguriert.</td></tr>}</tbody></table></div>
<p className="small muted" style={{ marginTop: 12 }}>Aufbewahrung: {s.retention.daily} tägliche, {s.retention.weekly} wöchentliche, {s.retention.monthly} monatliche Sicherungen (mindestens 3 bleiben immer erhalten). Alle Dateien sind verschlüsselt.</p></div>
<div className="card"><h3>Verlauf</h3>
{s.history.length === 0 ? <Empty title="Noch keine Sicherungen" /> : <div className="tablewrap"><table><thead><tr><th>Zeitpunkt</th><th>Art</th><th>Größe</th><th>Dauer</th><th>Ergebnis</th></tr></thead><tbody>
{s.history.map((h) => <tr key={h.at}><td>{fmt(h.at)}</td><td>{h.label ? LABEL[h.label] ?? h.label : '–'}</td><td>{size(h.sizeBytes)}</td><td>{dur(h.durationMs)}</td><td><span className={`badge ${h.ok ? 'ok' : 'err'}`}><span aria-hidden="true">{h.ok ? '✓' : '✕'}</span>{h.ok ? 'Erfolgreich' : 'Fehlgeschlagen'}</span>{h.error && <div className="small" style={{ color: 'var(--err)' }}>{h.error}</div>}</td></tr>)}</tbody></table></div>}</div>
<div className="card"><h3>Wichtig</h3>
<p>Die Sicherungen sind nur mit dem <strong>Backup-Passwort</strong> lesbar (bzw. bei älteren Sicherungen mit der Schlüsseldatei <span className="mono">/etc/kundencenter/backup-age-key.txt</span>). Bewahren Sie das Passwort <strong>außerhalb</strong> dieses Servers auf (z. B. Passwortmanager), sonst sind die Sicherungen bei Serververlust nicht lesbar.</p>
<p className="small muted" style={{ margin: 0 }}>Wiederherstellung im Notfall: siehe <span className="mono">docs/betrieb-backup-restore.md</span>. Das Lizenzsystem ist nicht Teil dieser Sicherung.</p></div>
</>);
}

View file

@ -0,0 +1,23 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSession } from '@/lib/session';
import { Alert } from '@/components/ui';
/** Einstellungen: ein Bereich mit Unterpunkten. Weitere Unterpunkte werden hier ergänzt. */
export default function SettingsLayout({ children }: { children: ReactNode }) {
const { can } = useSession(); const path = usePathname();
const tabs = [
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<>
<h1>Einstellungen</h1>
<nav aria-label="Einstellungen" className="row" style={{ gap: 8, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 8 }}>
{tabs.map((t) => { const cur = path === t.href || path.startsWith(t.href + '/'); return <Link key={t.href} href={t.href} aria-current={cur ? 'page' : undefined} className="btn" style={cur ? { background: 'var(--surface-2)', fontWeight: 600, borderColor: 'var(--accent)' } : undefined}>{t.label}</Link>; })}
</nav>
{children}
</>);
}

View file

@ -0,0 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from '@/lib/session';
export default function SettingsIndex() {
const { can, me } = useSession(); const r = useRouter();
useEffect(() => { if (me) r.replace(can('connectors.read') ? '/einstellungen/verbindungen' : '/einstellungen/backup'); }, [me, can, r]);
return <p className="muted" role="status">Wird geladen …</p>;
}

View file

@ -0,0 +1,50 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, fmt } from '@/components/ui';
import { ConfigFields, type CfgField } from '@/components/ConfigFields';
interface Inst { id: string; connector: string; name: string; enabled: boolean; syncIntervalSec: number; health: string; healthMessage: string | null; capabilities: string[]; lastOkAt: string | null; lastError?: { tech: string; hint: string } | null; lastErrorAt?: string | null; lastSyncAt: string | null; hasSecrets: boolean; resources: number; config?: Record<string, unknown> }
interface Type { key: string; name: string; fields: CfgField[] }
const HEALTH: Record<string, { cls: string; icon: string; label: string }> = { ok: { cls: 'ok', icon: '●', label: 'Erreichbar' }, down: { cls: 'err', icon: '✕', label: 'Gestört' }, unknown: { cls: 'info', icon: '○', label: 'Noch nicht geprüft' } };
export default function Connectors() {
const { can } = useSession(); const w = can('connectors.write');
const [list, setList] = useState<Inst[] | null>(null); const [types, setTypes] = useState<Type[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [open, setOpen] = useState(false); const [sel, setSel] = useState(''); const [editing, setEditing] = useState<Inst | null>(null);
const load = useCallback(() => api<Inst[]>('GET', '/admin/connectors').then(setList).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
useEffect(() => { void load(); api<Type[]>('GET', '/admin/connector-types').then((t) => { setTypes(t); setSel(t[0]?.key ?? ''); }).catch(() => undefined); const t = setInterval(() => void load(), 10000); return () => clearInterval(t); }, [load]);
const type = types.find((t) => t.key === sel);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const values: Record<string, string> = {};
for (const fl of type?.fields ?? []) { const v = f.get(fl.name) as string; if (v) values[fl.name] = v; }
try { await api('POST', '/admin/connectors', { connector: sel, name: f.get('name'), values }); setOpen(false); setMsg({ k: 'ok', t: 'Verbindung angelegt. Der erste Abgleich läuft im Hintergrund.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function saveEdit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!editing) return; const f = new FormData(e.currentTarget); const values: Record<string, string> = {};
for (const fl of types.find((t) => t.key === editing.connector)?.fields ?? []) { const v = (f.get(fl.name) as string)?.trim(); if (v) values[fl.name] = v; }
try { await api('PATCH', `/admin/connectors/${editing.id}`, { values }); await api('POST', `/admin/connectors/${editing.id}/sync`); setEditing(null); setMsg({ k: 'ok', t: 'Gespeichert. Ein Abgleich wurde eingeplant.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function sync(i: Inst) { try { await api('POST', `/admin/connectors/${i.id}/sync`); setMsg({ k: 'ok', t: `Abgleich für „${i.name}“ eingeplant.` }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function toggle(i: Inst) { try { await api('PATCH', `/admin/connectors/${i.id}`, { enabled: !i.enabled }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
return (<>
<div className="row between"><h2>Verbindungen</h2>{w && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Verbindung anlegen</button>}</div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{open && type && <form className="card" onSubmit={create}><h2>Neue Verbindung</h2>
{sel === 'keyhelp' && <Alert kind="info"><strong>KeyHelp einrichten:</strong> Im KeyHelp-Adminbereich unter <em>Konfiguration → API</em> die API aktivieren und einen Schlüssel anlegen. Den Schlüssel <strong>auf die IP-Adresse dieses Servers beschränken</strong> (er hat Administratorrechte) und hier eintragen. Die KeyHelp-Version wird automatisch erkannt. Bei einem selbstsignierten Zertifikat den SHA-256-Fingerabdruck angeben (sicherer als die Prüfung abzuschalten). Der Abgleich arbeitet zunächst nur lesend.</Alert>}
<div className="cols"><Field id="type" label="Typ"><select id="type" value={sel} onChange={(e) => setSel(e.target.value)}>{types.map((t) => <option key={t.key} value={t.key}>{t.name}</option>)}</select></Field>
<Field id="name" label="Name"><input id="name" name="name" required /></Field></div>
<ConfigFields fields={type.fields} prefix="f-" />
<div className="row"><button className="btn primary" type="submit">Anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <div className="card"><Empty title="Noch keine Verbindungen">{w ? 'Legen Sie die erste Verbindung an, z. B. zum Lizenzsystem.' : 'Ein Superadministrator kann Verbindungen einrichten.'}</Empty></div> :
list.map((i) => { const h = HEALTH[i.health] ?? HEALTH.unknown!; return (
<div className="card" key={i.id}><div className="row between"><h2 style={{ margin: 0 }}>{i.name}</h2><span className={`badge ${h.cls}`}><span aria-hidden="true">{h.icon}</span>{h.label}</span></div>
<p className="muted small">Typ: {types.find((t) => t.key === i.connector)?.name ?? i.connector} · {i.resources} Ressourcen · Abgleich alle {Math.round(i.syncIntervalSec / 60)} Min. {!i.enabled && '· deaktiviert'}</p>
{i.health === 'down' && <Alert kind="err">{i.healthMessage ?? 'Der Dienst ist nicht erreichbar.'} Zuletzt erreichbar: {i.lastOkAt ? fmt(i.lastOkAt) : 'nie'}. Kunden sehen bis dahin den zuletzt bekannten Stand.{i.lastError && <><br /><strong>Mögliche Ursache:</strong> {i.lastError.hint || 'Keine Einschätzung möglich.'}<details style={{ marginTop: 6 }}><summary style={{ cursor: 'pointer' }}>Technische Details</summary><code>{i.lastError.tech || '–'}</code>{i.lastErrorAt && <span className="muted small"> (Stand {fmt(i.lastErrorAt)})</span>}</details></>}</Alert>}
<p className="small">Letzter erfolgreicher Abgleich: {i.lastOkAt ? fmt(i.lastOkAt) : '–'} · Letzter Versuch: {i.lastSyncAt ? fmt(i.lastSyncAt) : '–'}</p>
<p className="small muted">Fähigkeiten: {i.capabilities.length ? i.capabilities.join(', ') : '–'}</p>
{editing?.id === i.id && <form onSubmit={saveEdit} style={{ margin: '16px 0' }}><p className="small muted">Leere Felder bleiben unverändert. Gespeicherte Zugangsdaten werden aus Sicherheitsgründen nie angezeigt.</p>
<ConfigFields fields={types.find((t) => t.key === i.connector)?.fields ?? []} prefix="e-" existing={i.config as Record<string, unknown>} hasSecrets={i.hasSecrets} />
<div className="row"><button className="btn primary" type="submit">Speichern</button><button className="btn" type="button" onClick={() => setEditing(null)}>Abbrechen</button></div></form>}
<div className="row"><button className="btn small" onClick={() => sync(i)}>Jetzt abgleichen</button>{w && <button className="btn small" onClick={() => setEditing(editing?.id === i.id ? null : i)} aria-expanded={editing?.id === i.id}>Zugangsdaten / Einstellungen ändern</button>}{w && <button className="btn small" onClick={() => toggle(i)}>{i.enabled ? 'Deaktivieren' : 'Aktivieren'}</button>}</div></div>); })}
</>);
}

View file

@ -0,0 +1,77 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import QRCode from 'qrcode';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
interface Sess { id: string; ip: string | null; userAgent: string | null; lastSeenAt: string; current: boolean }
export default function Konto() {
const { me, reload } = useSession();
const [sessions, setSessions] = useState<Sess[]>([]); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
const [resetOpen, setResetOpen] = useState(false);
const [setup, setSetup] = useState<{ secret: string; url: string; qr: string } | null>(null); const [codes, setCodes] = useState<string[] | null>(null);
const load = useCallback(() => api<Sess[]>('GET', '/auth/sessions').then(setSessions).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), []);
useEffect(() => { if (me && !me.mfaEnrollRequired) void load(); }, [me, load]);
if (!me) return null;
async function startMfa() {
try { const s = await api<{ secret: string; otpauthUrl: string }>('POST', '/auth/mfa/setup'); setSetup({ secret: s.secret, url: s.otpauthUrl, qr: await QRCode.toDataURL(s.otpauthUrl, { margin: 1, width: 220 }) }); }
catch (e) { setMsg({ k: 'err', t: errMsg(e) }); }
}
async function confirmMfa(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
try { const r = await api<{ recoveryCodes: string[] }>('POST', '/auth/mfa/confirm', { code: new FormData(e.currentTarget).get('code') }); setCodes(r.recoveryCodes); setSetup(null); await reload(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function disableMfa(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget);
try { await api('POST', '/auth/mfa/disable', { password: f.get('password'), code: f.get('code') }); setResetOpen(false); setCodes(null); await reload(); setMsg({ k: 'ok', t: 'Die 2FA wurde entfernt. Sie können sie jetzt mit dem neuen Gerät neu einrichten.' }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function changePw(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const form = e.currentTarget; const f = new FormData(form);
if (f.get('next') !== f.get('repeat')) { setMsg({ k: 'err', t: 'Die neuen Passwörter stimmen nicht überein. Bitte prüfen Sie Ihre Eingabe.' }); return; }
try { await api('POST', '/auth/password/change', { current: f.get('current'), next: f.get('next'), repeat: f.get('repeat') }); form.reset(); setMsg({ k: 'ok', t: 'Passwort geändert. Andere Sitzungen wurden abgemeldet.' }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
async function revoke(id: string) { try { await api('DELETE', `/auth/sessions/${id}`); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
return (<>
<h1>Mein Konto</h1>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{me.mfaEnrollRequired && <Alert kind="warn">Für Mitarbeiterkonten ist die Zwei-Faktor-Anmeldung verpflichtend. Bitte richten Sie sie jetzt ein, um fortzufahren.</Alert>}
<div className="card"><h2>Zwei-Faktor-Anmeldung</h2>
{codes ? (<><Alert kind="ok">2FA ist aktiv. Bewahren Sie diese Wiederherstellungscodes sicher auf. Sie werden nur jetzt angezeigt.</Alert><div className="codes mono">{codes.map((c) => <div key={c}>{c}</div>)}</div></>)
: me.mfaEnrolled ? (<>
<p><span className="badge ok"><span aria-hidden="true">●</span>Aktiv</span> Ihr Konto ist mit einer Authenticator-App geschützt.</p>
{resetOpen ? (<form onSubmit={disableMfa}>
<p><strong>2FA entfernen und mit neuem Gerät einrichten</strong><br /><span className="muted small">Zur Sicherheit benötigen wir Ihr Passwort und einen aktuellen Code (oder einen Wiederherstellungscode). Andere angemeldete Sitzungen werden beendet.</span></p>
<div className="cols"><Field id="mpw" label="Passwort"><input id="mpw" name="password" type="password" autoComplete="current-password" required /></Field>
<Field id="mcode" label="Code oder Wiederherstellungscode"><input id="mcode" name="code" autoComplete="one-time-code" required /></Field></div>
<div className="row"><button className="btn danger" type="submit">2FA entfernen</button><button className="btn" type="button" onClick={() => setResetOpen(false)}>Abbrechen</button></div>
<p className="small muted" style={{ marginTop: 12 }}>Haben Sie weder Handy noch Wiederherstellungscodes mehr? Dann kann der Support Ihre 2FA zurücksetzen.</p></form>)
: <button className="btn" onClick={() => setResetOpen(true)}>Neues Handy? 2FA zurücksetzen</button>}
</>)
: setup ? (<form onSubmit={confirmMfa}>
<p>Scannen Sie den QR-Code mit einer Authenticator-App und geben Sie den angezeigten Code ein.</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={setup.qr} width={220} height={220} alt="QR-Code für die Authenticator-App" />
<p className="small muted">Oder Schlüssel manuell eingeben: <code>{setup.secret}</code></p>
<Field id="code" label="6-stelliger Code"><input id="code" name="code" inputMode="numeric" pattern="\d{6}" autoComplete="one-time-code" required /></Field>
<button className="btn primary" type="submit">Aktivieren</button></form>)
: <><p>Schützen Sie Ihr Konto zusätzlich mit einem Code aus einer Authenticator-App.</p><button className="btn primary" onClick={startMfa}>2FA einrichten</button></>}
</div>
{!me.mfaEnrollRequired && (<>
<div className="card"><h2>Passwort ändern</h2><form onSubmit={changePw}>
<div className="cols"><Field id="current" label="Aktuelles Passwort"><input id="current" name="current" type="password" autoComplete="current-password" required /></Field>
<Field id="next" label="Neues Passwort" hint="Mindestens 12 Zeichen."><input id="next" name="next" type="password" autoComplete="new-password" minLength={12} required /></Field>
<Field id="repeat" label="Neues Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field></div>
<button className="btn" type="submit">Speichern</button></form></div>
<div className="card"><h2>Aktive Sitzungen</h2>
<div className="tablewrap"><table><thead><tr><th>Gerät</th><th>IP</th><th>Zuletzt aktiv</th><th><span className="sr">Aktion</span></th></tr></thead><tbody>
{sessions.map((s) => <tr key={s.id}><td>{(s.userAgent ?? 'Unbekannt').slice(0, 60)} {s.current && <span className="badge info">Diese Sitzung</span>}</td><td>{s.ip}</td><td>{fmt(s.lastSeenAt)}</td>
<td>{!s.current && <button className="btn small danger" onClick={() => revoke(s.id)}>Beenden</button>}</td></tr>)}
</tbody></table></div></div></>)}
</>);
}

View file

@ -0,0 +1,47 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useSession } from '@/lib/session';
import { ROLE_LABEL } from '@/components/ui';
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 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]);
useEffect(() => setOpen(false), [path]);
if (loading || !me || me.pendingMfa) return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>;
const link = (href: string, label: string) => <Link href={href} aria-current={path === href || path.startsWith(href + '/') ? 'page' : undefined}>{label}</Link>;
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
const nav = (
<nav className={`nav ${open ? 'open' : ''}`} aria-label="Hauptnavigation">
<div className="brand">Kundencenter</div>
{!enrollNeeded && link('/dashboard', 'Übersicht')}
{!enrollNeeded && (me.kind === 'customer' || can('resources.read')) && link('/ressourcen', 'Ressourcen')}
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')}
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && <>
<div className="group">Verwaltung</div>
{can('customers.read') && link('/admin/kunden', 'Kunden')}
{can('products.read') && link('/admin/produkte', 'Produkte')}
{can('domains.read') && link('/admin/domains', 'Domain-Aufstellung')}
{can('users.read') && link('/admin/benutzer', 'Benutzer')}
{can('jobs.read') && link('/admin/auftraege', 'Aufträge')}
{can('audit.read') && link('/admin/audit', 'Audit-Protokoll')}
{(can('connectors.read') || can('backup.read')) && link('/einstellungen', 'Einstellungen')}
</>}
<div className="spacer" />
{link('/konto', 'Mein Konto')}
<div className="small muted" style={{ padding: '8px 16px' }}>{me.user.name}<br />{me.staffRole ? ROLE_LABEL[me.staffRole] : 'Kunde'}</div>
<button className="btn small" onClick={logout}>Abmelden</button>
</nav>
);
return (<div className="shell">
<div className="topbar"><strong>Kundencenter</strong><button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>Menü</button></div>
{nav}<main id="main" className="main">{children}</main>
</div>);
}

View file

@ -0,0 +1,35 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, ROLE_LABEL, Status } from '@/components/ui';
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
export default function Organisation() {
const { me } = useSession(); const org0 = me?.organizations[0];
const [org, setOrg] = useState<Org | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null);
const load = useCallback(() => org0 && api<Org>('GET', `/orgs/${org0.id}`).then(setOrg).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [org0]);
useEffect(() => { void load(); }, [load]);
if (!org0) return <p>Ihrem Konto ist keine Organisation zugeordnet.</p>;
const canInvite = org0.role === 'owner' || org0.role === 'admin';
async function invite(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const form = e.currentTarget; const f = new FormData(form);
try { const r = await api<{ mail: string; inviteLink?: string }>('POST', `/orgs/${org0!.id}/invitations`, { email: f.get('email'), name: f.get('name'), role: f.get('role') });
form.reset(); setMsg(r.mail === 'sent' ? { k: 'ok', t: 'Einladung wurde per E-Mail versendet.' } : { k: 'warn', t: `E-Mail-Versand ist nicht eingerichtet. Einladungslink (einmalig anzeigen): ${r.inviteLink}` }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
<h1>{org?.name ?? org0.name}</h1><p className="muted">Kundennummer {org0.customerNumber}</p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{org && <div className="card"><h2>Rechnungsanschrift</h2>
<p>{[org.customerType === 'business' ? (org.billing.company ?? org.name) : org.name, org.billing.street, [org.billing.zip, org.billing.city].filter(Boolean).join(' '), org.billing.country].filter(Boolean).join(', ') || <span className="muted">Keine Anschrift hinterlegt.</span>}</p>
<p className="small muted">{org.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}{org.billing.vatId ? ` · USt-IdNr. ${org.billing.vatId}` : ''} · Rechnungs-E-Mail: {org.billing.billingEmail ?? '–'}</p></div>}
<div className="card"><h2>Mitglieder</h2>
<div className="tablewrap"><table><thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Status</th></tr></thead><tbody>
{org?.members.map((m) => <tr key={m.id}><td>{m.name}</td><td>{m.email}</td><td>{ROLE_LABEL[m.role]}</td><td><Status value={m.status} /></td></tr>)}</tbody></table></div></div>
{canInvite && <div className="card"><h2>Mitarbeiter einladen</h2><form onSubmit={invite}><div className="cols">
<Field id="name" label="Name"><input id="name" name="name" required /></Field><Field id="email" label="E-Mail"><input id="email" name="email" type="email" required /></Field>
<Field id="role" label="Rolle"><select id="role" name="role"><option value="member">Mitglied (nur lesen)</option><option value="admin">Administrator</option></select></Field></div>
<button className="btn primary" type="submit">Einladung senden</button></form></div>}
</>);
}

View file

@ -0,0 +1,79 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { ACTION_LABEL, Alert, Field, JobStatus, ResState, fmt, CUSTOMER_ACTIONS } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
import { uuid } from '@/lib/uuid';
import { HostingPanel, UsageBars } from '@/components/HostingPanel';
interface Det { id: string; type: string; name: string; state: string; orgId: string | null; validFrom: string | null; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; missing: boolean; instance?: string; connector?: string;
canReveal?: boolean; hasChildren?: boolean; canLogin?: boolean; limits: Record<string, unknown>; usage: Record<string, unknown>; details: Record<string, any>; allowedActions: string[]; customerActions?: string[]; jobs: { id: string; action: string; status: string; error: string | null; createdAt: string }[] }
const LIMIT_LABEL: Record<string, string> = { users: 'Benutzer', storageMb: 'Speicher (MB)' };
export default function Ressource() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('resources.read'); const w = can('resources.write');
const [d, setD] = useState<Det | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null);
const [confirm, setConfirm] = useState<{ action: string; days?: number } | null>(null); const key = useRef(uuid());
const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]);
const load = useCallback(() => api<Det>('GET', `/resources/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (w) api<typeof custs>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]);
// Laufende Aufträge regelmäßig aktualisieren
const pending = d?.jobs.some((j) => ['scheduled', 'running', 'retrying', 'waiting_external'].includes(j.status));
useEffect(() => { if (!pending) return; const t = setInterval(() => void load(), 3000); return () => clearInterval(t); }, [pending, load]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function run(a: { action: string; days?: number }) {
try { await fetchAction(a); setConfirm(null); key.current = uuid(); setMsg({ k: 'ok', t: 'Der Auftrag wurde angenommen und wird im Hintergrund ausgeführt.' }); void load(); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
// Idempotency-Key pro Bestätigungsdialog: Doppelklicks erzeugen keinen zweiten Auftrag
async function fetchAction(a: { action: string; days?: number }) {
const csrf = (await api<{ csrf: string }>('GET', '/auth/me')).csrf;
const res = await fetch(`/api/resources/${id}/actions`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf, 'idempotency-key': key.current }, body: JSON.stringify(a) });
if (!res.ok) { const j = await res.json().catch(() => null); throw new Error(j?.error?.message ?? 'Anfrage fehlgeschlagen'); }
}
async function assign(orgId: string) { try { await api('PATCH', `/admin/resources/${id}`, { orgId: orgId || null }); setMsg({ k: 'ok', t: 'Zuordnung gespeichert.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function setActions(list: string[]) { try { await api('PATCH', `/admin/resources/${id}`, { customerActions: list }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
const act = d.allowedActions;
return (<>
<p><Link href="/ressourcen">← Alle Ressourcen</Link></p>
<div className="row between"><h1>{d.name}</h1><ResState value={d.state} /></div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{d.stale && <Alert kind="warn"><strong>Angezeigt wird der zuletzt bekannte Stand vom {fmt(d.syncedAt)}.</strong> {d.staleReason} Änderungen sind währenddessen nicht möglich.</Alert>}
{d.missing && <Alert kind="warn">Diese Ressource wurde beim letzten Abgleich nicht mehr beim Anbieter gefunden.</Alert>}
<div className="card"><h2>Details</h2>
<dl className="cols" style={{ margin: 0 }}>
<div><dt className="muted small">Gültig ab</dt><dd style={{ margin: 0 }}>{d.validFrom ? fmt(d.validFrom) : '–'}</dd></div>
<div><dt className="muted small">Gültig bis</dt><dd style={{ margin: 0 }}>{d.validUntil ? fmt(d.validUntil) : 'unbefristet'}</dd></div>
{d.type !== 'hosting_account' && Object.entries(d.limits).map(([k, v]) => <div key={k}><dt className="muted small">{LIMIT_LABEL[k] ?? k}</dt><dd style={{ margin: 0 }}>{v === null ? 'unbegrenzt' : String(v)}{d.usage[k] !== undefined ? ` (genutzt: ${String(d.usage[k])})` : ''}</dd></div>)}
{d.details.edition && <div><dt className="muted small">Edition</dt><dd style={{ margin: 0 }}>{String(d.details.edition)}</dd></div>}
{d.details.program && <div><dt className="muted small">Programm</dt><dd style={{ margin: 0 }}>{String(d.details.program)}</dd></div>}
{d.details.licenseKeyMasked && <div><dt className="muted small">Lizenzschlüssel</dt><dd className="mono" style={{ margin: 0 }}>{String(d.details.licenseKeyMasked)}</dd></div>}
{d.details.activation ? <>
<div><dt className="muted small">Aktiviert am</dt><dd style={{ margin: 0 }}>{fmt(d.details.activation.activatedAt)}</dd></div>
<div><dt className="muted small">Letzte Prüfung</dt><dd style={{ margin: 0 }}>{fmt(d.details.activation.lastCheckAt)}</dd></div>
<div><dt className="muted small">Gerät</dt><dd className="mono" style={{ margin: 0 }}>{d.details.activation.hardwareIdMasked}</dd></div></> : d.type === 'license' ? <div><dt className="muted small">Aktivierung</dt><dd style={{ margin: 0 }}>Noch nicht aktiviert</dd></div> : null}
</dl>
<p className="small muted" style={{ marginTop: 16 }}>Stand: {fmt(d.syncedAt)}{staff && d.instance ? ` · Verbindung: ${d.instance}` : ''}</p></div>
{d.type === 'hosting_account' && <UsageBars usage={d.usage} limits={d.limits} />}
{(d.hasChildren || d.canLogin) && <HostingPanel resourceId={d.id} canLogin={!!d.canLogin} />}
{d.canReveal && <div className="card"><h2>Lizenzschlüssel</h2><SecretField resourceId={d.id} /></div>}
<div className="card"><h2>Aktionen</h2>
{act.length === 0 ? <p className="muted">Für diese Ressource sind aktuell keine Aktionen verfügbar.</p> : confirm ? (
<div role="alertdialog" aria-labelledby="cd"><p id="cd"><strong>{ACTION_LABEL[confirm.action]}{confirm.days ? ` um ${confirm.days} Tage` : ''}?</strong> Die Änderung wird als Auftrag ausgeführt und protokolliert.</p>
<div className="row"><button className="btn primary" onClick={() => run(confirm)}>Ja, ausführen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>
) : <div className="row">{act.includes('suspend') && d.state !== 'suspended' && <button className="btn danger" onClick={() => setConfirm({ action: 'suspend' })}>Sperren</button>}
{act.includes('unsuspend') && d.state === 'suspended' && <button className="btn" onClick={() => setConfirm({ action: 'unsuspend' })}>Entsperren</button>}
{act.includes('extend') && [30, 365].map((n) => <button className="btn" key={n} onClick={() => setConfirm({ action: 'extend', days: n })}>Um {n} Tage verlängern</button>)}</div>}
</div>
{d.jobs.length > 0 && <div className="card"><h2>Letzte Aufträge</h2><div className="tablewrap"><table><thead><tr><th>Aktion</th><th>Status</th><th>Angelegt</th></tr></thead><tbody>
{d.jobs.map((j) => <tr key={j.id}><td>{ACTION_LABEL[j.action] ?? j.action}</td><td><JobStatus value={j.status} />{j.error && j.status !== 'succeeded' && <div className="small muted">{j.error}</div>}</td><td>{fmt(j.createdAt)}</td></tr>)}</tbody></table></div></div>}
{w && <div className="card"><h2>Verwaltung</h2>
<Field id="org" label="Zugeordneter Kunde"><select id="org" value={d.orgId ?? ''} onChange={(e) => assign(e.target.value)}><option value="">— nicht zugewiesen —</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>
<fieldset style={{ border: 0, padding: 0, margin: 0 }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Diese Aktionen darf der Kunde selbst ausführen</legend>
<div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={!!d.customerActions?.includes(a)} onChange={(e) => setActions(e.target.checked ? [...(d.customerActions ?? []), a] : (d.customerActions ?? []).filter((x) => x !== a))} />{ACTION_LABEL[a]}</label>)}</div></fieldset></div>}
</>);
}

View file

@ -0,0 +1,63 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, ResState, fmt } from '@/components/ui';
import { SecretField } from '@/components/SecretField';
interface R { id: string; type: string; name: string; state: string; validUntil: string | null; syncedAt: string; stale: boolean; staleReason: string | null; orgId: string | null; instance?: string; canReveal?: boolean }
const TYPE: Record<string, string> = { license: 'Lizenz', hosting_account: 'Hosting-Konto', domain: 'Domain', server: 'Server' };
const GROUPS = [{ key: 'hosting', title: 'Webhosting', types: ['hosting_account', 'domain', 'server'] }, { key: 'license', title: 'Lizenzen', types: ['license'] }];
interface Cust { id: string; name: string; customerNumber: string }
const NEW = '__new__';
type Created = { id: string; customerNumber: string; mail: string; inviteLink?: string };
/** Zuweisung eines Kunden an eine noch nicht zugeordnete Ressource (nur Personal mit Schreibrecht); Kunde kann direkt neu angelegt werden. */
function AssignCell({ r, custs, onDone, onError, onCreated }: { r: R; custs: Cust[]; onDone: () => void; onError: (m: string) => void; onCreated: (c: Cust, r: Created) => void }) {
const [org, setOrg] = useState(''); const [busy, setBusy] = useState(false); const [priv, setPriv] = useState(false);
async function assign() { if (!org || org === NEW) return; setBusy(true); try { await api('PATCH', `/admin/resources/${r.id}`, { orgId: org }); onDone(); } catch (e) { onError(errMsg(e)); } finally { setBusy(false); } }
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.stopPropagation(); const f = new FormData(e.currentTarget); const v = (k: string) => String(f.get(k) ?? '').trim(); setBusy(true);
try {
const name = priv ? `${v('first')} ${v('last')}`.trim() : v('name');
const c = await api<Created>('POST', '/admin/customers', { type: priv ? 'private' : 'business', name, owner: priv ? { email: v('email') } : { name: v('owner'), email: v('email') }, billing: { country: 'DE' } });
onCreated({ id: c.id, name, customerNumber: c.customerNumber }, c); setOrg(c.id);
} catch (x) { onError(errMsg(x)); } finally { setBusy(false); }
}
return (<div>
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<select aria-label={`Kunde für ${r.name} auswählen`} value={org} onChange={(e) => setOrg(e.target.value)} style={{ minWidth: 200, minHeight: 36, padding: '4px 8px' }}><option value="">Kunde wählen …</option><option value={NEW}>+ Neuen Kunden anlegen …</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select>
<button className="btn small primary" disabled={!org || org === NEW || busy} onClick={assign}>{busy ? '…' : 'Zuweisen'}</button></div>
{org === NEW && <form onSubmit={create} style={{ marginTop: 8, padding: 12, border: '1px solid var(--border)', borderRadius: 8, maxWidth: 420 }}>
<div className="row" style={{ marginBottom: 8 }}>{([[false, 'Geschäftskunde'], [true, 'Privatkunde']] as const).map(([v, l]) => <label key={l} className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={priv === v} onChange={() => setPriv(v)} /> {l}</label>)}</div>
{priv ? <div className="cols"><Field id={`f-${r.id}`} label="Vorname"><input id={`f-${r.id}`} name="first" required autoFocus /></Field><Field id={`l-${r.id}`} label="Nachname"><input id={`l-${r.id}`} name="last" required /></Field></div>
: <div className="cols"><Field id={`n-${r.id}`} label="Firmenname"><input id={`n-${r.id}`} name="name" required autoFocus /></Field><Field id={`o-${r.id}`} label="Ansprechpartner"><input id={`o-${r.id}`} name="owner" required /></Field></div>}
<Field id={`e-${r.id}`} label="E-Mail (erhält die Einladung)"><input id={`e-${r.id}`} name="email" type="email" required /></Field>
<div className="row"><button className="btn small primary" type="submit" disabled={busy}>Kunde anlegen</button><button className="btn small" type="button" onClick={() => setOrg('')}>Abbrechen</button></div>
<p className="muted small">Adresse und weitere Angaben können danach unter Kunden ergänzt werden.</p></form>}
</div>);
}
export default function Ressourcen() {
const { can } = useSession(); const staff = can('resources.read'); const canAssign = can('resources.write');
const [custs, setCusts] = useState<Cust[]>([]); const [ok, setOk] = useState('');
const [list, setList] = useState<R[] | null>(null); const [err, setErr] = useState(''); const [unassigned, setUnassigned] = useState(false);
const reload = () => api<R[]>('GET', `/resources${unassigned ? '?unassigned=1' : ''}`).then(setList).catch((e) => setErr(errMsg(e)));
useEffect(() => { void reload(); }, [unassigned]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (canAssign) api<Cust[]>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]);
const stale = list?.find((r) => r.stale);
return (<>
<h1>Ressourcen</h1>
{err && <Alert kind="err">{err}</Alert>}
{ok && <Alert kind="ok">{ok}</Alert>}
{stale && <Alert kind="warn">Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.</Alert>}
{staff && <label className="row small" style={{ marginBottom: 16 }}><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={unassigned} onChange={(e) => setUnassigned(e.target.checked)} /> Nur nicht zugewiesene Ressourcen</label>}
{list === null ? <div className="card"><p className="muted" role="status">Wird geladen …</p></div> : list.length === 0 ? <div className="card"><Empty title="Keine Ressourcen">{staff ? 'Sobald eine Verbindung eingerichtet und abgeglichen ist, erscheinen die Ressourcen hier.' : 'Für Ihre Organisation sind noch keine Ressourcen hinterlegt.'}</Empty></div> :
GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return (
<section key={g.key} aria-labelledby={`h-${g.key}`} style={{ marginBottom: 24 }}>
<h2 id={`h-${g.key}`}>{g.title} <span className="muted small">({rows.length})</span></h2>
<div className="card"><div className="tablewrap"><table><thead><tr><th>Name</th>{!lic && <th>Typ</th>}<th>Status</th><th>Gültig bis</th><th>Stand</th>{lic && <th>Lizenzschlüssel</th>}</tr></thead><tbody>
{rows.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{staff && !r.orgId && <> <span className="badge warn"><span aria-hidden="true">▲</span>Nicht zugewiesen</span></>}{canAssign && !r.orgId && <div style={{ marginTop: 8 }}><AssignCell r={r} custs={custs} onError={setErr} onCreated={(c, cr) => { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /></div>}</td>{!lic && <td>{TYPE[r.type] ?? r.type}</td>}<td><ResState value={r.state} /></td>
<td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td className="small">{fmt(r.syncedAt)}{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td>{lic && <td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td>}</tr>)}</tbody></table></div></div>
</section>); })}
</>);
}

View file

@ -0,0 +1,41 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, ContractStatusBadge, fmt, INTERVAL, type Price, PriceText } from '@/components/ui';
interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean;
price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number } } }
export default function Vertrag() {
const { id } = useParams<{ id: string }>(); const { can } = useSession();
const [d, setD] = useState<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null);
const load = useCallback(() => api<D>('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
useEffect(() => { void load(); }, [load]);
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
async function cancel(immediate: boolean) { try { const r = await api<{ cancelEffectiveAt: string }>('POST', `/contracts/${id}/cancel`, { immediate }); setConfirm(null); setMsg({ k: 'ok', t: `Die Kündigung wurde vorgemerkt und wird wirksam am ${fmt(r.cancelEffectiveAt)}.` }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function revoke() { try { await api('POST', `/contracts/${id}/cancel/revoke`); setMsg({ k: 'ok', t: 'Die Kündigung wurde zurückgenommen.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
const priv = d.price.customerType === 'private'; const pending = !!d.cancelEffectiveAt && ['active', 'suspended'].includes(d.status);
return (<>
<p><Link href="/vertraege">← Alle Verträge</Link></p>
<div className="row between"><h1>{d.productName}</h1><ContractStatusBadge value={d.status} /></div><p className="muted"><span className="mono">{d.number}</span> · {d.orgName}</p>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{pending && <Alert kind="warn">Dieser Vertrag ist gekündigt und endet am <strong>{fmt(d.cancelEffectiveAt!)}</strong>.</Alert>}
<div className="card"><h2>Konditionen</h2>
<dl className="cols" style={{ margin: 0 }}>
<div><dt className="muted small">Preis</dt><dd style={{ margin: 0 }}><PriceText p={d.price} priv={priv} /></dd></div>
<div><dt className="muted small">Beginn</dt><dd style={{ margin: 0 }}>{d.startedAt ? fmt(d.startedAt) : '–'}</dd></div>
<div><dt className="muted small">Laufzeit bis</dt><dd style={{ margin: 0 }}>{d.termEnd ? fmt(d.termEnd) : 'unbefristet'}</dd></div>
<div><dt className="muted small">Verlängerung</dt><dd style={{ margin: 0 }}>{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}</dd></div>
<div><dt className="muted small">Kündigungsfrist</dt><dd style={{ margin: 0 }}>{d.noticeDays} Tage</dd></div>
<div><dt className="muted small">Abrechnung</dt><dd style={{ margin: 0 }}>{INTERVAL[d.price.interval]}</dd></div></dl>
<p className="small muted" style={{ marginTop: 16 }}>Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.</p>
{d.resourceId && <p><Link href={`/ressourcen/${d.resourceId}`}>Zugehörige Ressource ansehen</Link></p>}</div>
{(d.canCancel || pending) && <div className="card"><h2>Kündigung</h2>
{confirm ? (<div role="alertdialog" aria-labelledby="cc"><p id="cc"><strong>{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'}</strong> {confirm === 'immediate' ? 'Die Ressource wird gesperrt.' : 'Die Kündigung wird zum nächstmöglichen Termin wirksam; bis dahin läuft der Vertrag weiter.'} Die Aktion wird protokolliert.</p>
<div className="row"><button className="btn danger" onClick={() => cancel(confirm === 'immediate')}>Ja, kündigen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)
: pending ? (new Date(d.cancelEffectiveAt!) > new Date() && <button className="btn" onClick={revoke}>Kündigung zurücknehmen</button>)
: <div className="row"><button className="btn danger" onClick={() => setConfirm('cancel')}>Vertrag kündigen</button>{can('contracts.write') && <button className="btn danger" onClick={() => setConfirm('immediate')}>Sofort beenden (Personal)</button>}</div>}</div>}
</>);
}

View file

@ -0,0 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, ContractStatusBadge, Empty, Field, fmt, type Price, PriceText } from '@/components/ui';
interface C { id: string; number: string; orgName: string; status: string; productName: string; termEnd: string | null; cancelEffectiveAt: string | null; price: Price & { customerType: string } }
export default function Vertraege() {
const { can } = useSession(); const staff = can('contracts.read');
const [list, setList] = useState<C[] | null>(null); const [status, setStatus] = useState(''); const [err, setErr] = useState('');
useEffect(() => { api<C[]>('GET', `/contracts${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))); }, [status]);
return (<>
<h1>Verträge</h1>{err && <Alert kind="err">{err}</Alert>}
<div className="card"><Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="active">Aktiv</option><option value="pending">In Vorbereitung</option><option value="suspended">Gesperrt</option><option value="cancelled">Beendet</option></select></Field>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Verträge">Verträge entstehen aus Bestellungen.</Empty> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Produkt</th>{staff && <th>Kunde</th>}<th>Preis</th><th>Laufzeit bis</th><th>Status</th></tr></thead><tbody>
{list.map((c) => <tr key={c.id}><td className="mono"><Link href={`/vertraege/${c.id}`}>{c.number}</Link></td><td>{c.productName}</td>{staff && <td>{c.orgName}</td>}<td className="small"><PriceText p={c.price} priv={c.price.customerType === 'private'} /></td>
<td>{c.termEnd ? fmt(c.termEnd) : 'unbefristet'}{c.cancelEffectiveAt && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Gekündigt zum {fmt(c.cancelEffectiveAt)}</span></div>}</td><td><ContractStatusBadge value={c.status} /></td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,25 @@
'use client';
import { Suspense, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert, Field } from '@/components/ui';
function Form() {
const token = useSearchParams().get('token') ?? '';
const [done, setDone] = useState(false); const [err, setErr] = useState('');
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr('');
const f = new FormData(e.currentTarget);
if (f.get('password') !== f.get('repeat')) { setErr('Die Passwörter stimmen nicht überein.'); return; }
try { await api('POST', '/auth/invite/accept', { token, password: f.get('password'), repeat: f.get('repeat') }); setDone(true); } catch (x) { setErr(errMsg(x)); }
}
return <main id="main" className="center"><div className="auth card"><h1>Zugang einrichten</h1>
{!token ? <Alert kind="err">Der Link ist unvollständig.</Alert> : done ? <><Alert kind="ok">Ihr Zugang ist eingerichtet.</Alert><Link className="btn primary" href="/login">Zur Anmeldung</Link></> : (
<form onSubmit={submit}>{err && <Alert kind="err">{err}</Alert>}
<Field id="password" label="Passwort" hint="Mindestens 12 Zeichen."><input id="password" name="password" type="password" autoComplete="new-password" minLength={12} required autoFocus /></Field>
<Field id="repeat" label="Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field>
<button className="btn primary" type="submit">Passwort festlegen</button></form>)}
</div></main>;
}
export default function Page() { return <Suspense><Form /></Suspense>; }

View file

@ -0,0 +1,56 @@
/* Designsystem v0: ruhige Flächen, viel Weißraum, eine Akzentfarbe. Light/Dark über prefers-color-scheme + data-theme. */
:root {
--bg: #f7f8fa; --surface: #ffffff; --surface-2: #f0f2f5; --border: #dfe3e8; --text: #16191d; --muted: #5b6470;
--accent: #2456d6; --accent-contrast: #ffffff; --focus: #2456d6;
--ok: #17703a; --ok-bg: #e4f5ea; --info: #1d5fa8; --info-bg: #e3effb; --warn: #8a5a00; --warn-bg: #fdf0d2; --err: #a52a2a; --err-bg: #fbe5e5; --lock: #4b5563; --lock-bg: #e8eaee;
--radius: 12px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) {
--bg: #0f1216; --surface: #171b21; --surface-2: #1f242c; --border: #2b323c; --text: #eceff3; --muted: #9aa5b3; --accent: #7aa2ff; --accent-contrast: #0b1020; --focus: #9db9ff;
--ok: #6fd39a; --ok-bg: #12301f; --info: #8bbcf2; --info-bg: #142a42; --warn: #f0c063; --warn-bg: #3a2c0b; --err: #f29b9b; --err-bg: #3d1717; --lock: #b6bfcb; --lock-bg: #262c35; } }
:root[data-theme='dark'] {
--bg: #0f1216; --surface: #171b21; --surface-2: #1f242c; --border: #2b323c; --text: #eceff3; --muted: #9aa5b3; --accent: #7aa2ff; --accent-contrast: #0b1020; --focus: #9db9ff;
--ok: #6fd39a; --ok-bg: #12301f; --info: #8bbcf2; --info-bg: #142a42; --warn: #f0c063; --warn-bg: #3a2c0b; --err: #f29b9b; --err-bg: #3d1717; --lock: #b6bfcb; --lock-bg: #262c35;
}
* { box-sizing: border-box; }
html { color-scheme: light dark; }
body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); }
h1 { font-size: 1.6rem; margin: 0 0 var(--s3); letter-spacing: -0.01em; } h2 { font-size: 1.15rem; margin: 0 0 var(--s3); } h3 { font-size: 1rem; margin: 0 0 var(--s2); }
a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .875rem; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
.skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; }
.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
.nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--s4) var(--s3); display: flex; flex-direction: column; gap: var(--s2); }
.nav .brand { font-weight: 700; font-size: 1.1rem; padding: 0 var(--s3) var(--s3); }
.nav a { display: block; padding: 10px var(--s3); border-radius: 8px; color: var(--text); text-decoration: none; }
.nav a:hover { background: var(--surface-2); } .nav a[aria-current='page'] { background: var(--surface-2); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); }
.nav .group { margin: var(--s3) var(--s3) 0; font-size: .75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.nav .spacer { flex: 1; }
.main { padding: var(--s5) var(--s4); max-width: 1100px; width: 100%; }
.topbar { display: none; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 30% 0 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); }
.main { padding: var(--s4) var(--s3); }
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s4); margin-bottom: var(--s4); }
.grid { display: grid; gap: var(--s4); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.stat { font-size: 2rem; font-weight: 700; line-height: 1.1; } .row { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; } .row.between { justify-content: space-between; }
.btn { font: inherit; padding: 10px 18px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; min-height: 44px; }
.btn:hover { background: var(--surface-2); } .btn.primary { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } .btn.primary:hover { filter: brightness(1.08); }
.btn.danger { color: var(--err); border-color: var(--err); } .btn:disabled { opacity: .55; cursor: not-allowed; } .btn.small { min-height: 36px; padding: 6px 12px; }
.field { display: grid; gap: 6px; margin-bottom: var(--s3); } .field label { font-weight: 600; font-size: .9rem; }
input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); min-height: 44px; width: 100%; }
.cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.tablewrap { overflow-x: auto; }
.badge { display: inline-flex; gap: 6px; align-items: center; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
.badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); }
.alert { border-radius: 8px; padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); }
.empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); }
.center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: 8px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }

View file

@ -0,0 +1,9 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import './globals.css';
import { SessionProvider } from '@/lib/session';
export const metadata: Metadata = { title: { default: 'Kundencenter', template: '%s · Kundencenter' }, robots: { index: false } };
export default function RootLayout({ children }: { children: ReactNode }) {
return <html lang="de"><body><a className="skip" href="#main">Zum Inhalt springen</a><SessionProvider>{children}</SessionProvider></body></html>;
}

View file

@ -0,0 +1,45 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api, errMsg, setCsrf } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field } from '@/components/ui';
export default function Login() {
const r = useRouter(); const { me, reload } = useSession();
const [step, setStep] = useState<'pw' | 'mfa'>('pw');
const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
useEffect(() => { if (me?.pendingMfa) setStep('mfa'); else if (me) r.replace('/dashboard'); }, [me, r]);
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true);
const f = new FormData(e.currentTarget);
try {
if (step === 'pw') {
const res = await api<{ status: string; csrf: string }>('POST', '/auth/login', { email: f.get('email'), password: f.get('password') });
setCsrf(res.csrf);
if (res.status === 'mfa_required') { await reload(); setStep('mfa'); } else { await reload(); r.replace('/dashboard'); }
} else {
await api('POST', '/auth/mfa/verify', { code: f.get('code') });
await reload(); r.replace('/dashboard');
}
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (
<main id="main" className="center"><div className="auth card">
<h1>{step === 'pw' ? 'Anmelden' : 'Zweiter Faktor'}</h1>
{err && <Alert kind="err">{err}</Alert>}
<form onSubmit={submit}>
{step === 'pw' ? (<>
<Field id="email" label="E-Mail"><input id="email" name="email" type="email" autoComplete="username" required autoFocus /></Field>
<Field id="password" label="Passwort"><input id="password" name="password" type="password" autoComplete="current-password" required /></Field>
</>) : (
<Field id="code" label="Code aus der Authenticator-App" hint="Alternativ ein Wiederherstellungscode."><input id="code" name="code" inputMode="text" autoComplete="one-time-code" required autoFocus /></Field>
)}
<button className="btn primary" disabled={busy} type="submit">{busy ? 'Bitte warten …' : step === 'pw' ? 'Anmelden' : 'Bestätigen'}</button>
</form>
{step === 'pw' && <p className="small" style={{ marginTop: 16 }}><Link href="/passwort-vergessen">Passwort vergessen?</Link></p>}
</div></main>
);
}

View file

@ -0,0 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from '@/lib/session';
export default function Home() {
const { me, loading } = useSession(); const r = useRouter();
useEffect(() => { if (!loading) r.replace(me && !me.pendingMfa ? '/dashboard' : '/login'); }, [me, loading, r]);
return <div className="center"><p className="muted" role="status">Wird geladen …</p></div>;
}

View file

@ -0,0 +1,25 @@
'use client';
import { Suspense, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { Alert, Field } from '@/components/ui';
function Form() {
const token = useSearchParams().get('token') ?? '';
const [done, setDone] = useState(false); const [err, setErr] = useState('');
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr('');
const f = new FormData(e.currentTarget);
if (f.get('password') !== f.get('repeat')) { setErr('Die Passwörter stimmen nicht überein.'); return; }
try { await api('POST', '/auth/password/reset', { token, password: f.get('password'), repeat: f.get('repeat') }); setDone(true); } catch (x) { setErr(errMsg(x)); }
}
return <main id="main" className="center"><div className="auth card"><h1>Neues Passwort</h1>
{!token ? <Alert kind="err">Der Link ist unvollständig.</Alert> : done ? <><Alert kind="ok">Das Passwort wurde geändert.</Alert><Link className="btn primary" href="/login">Zur Anmeldung</Link></> : (
<form onSubmit={submit}>{err && <Alert kind="err">{err}</Alert>}
<Field id="password" label="Passwort" hint="Mindestens 12 Zeichen."><input id="password" name="password" type="password" autoComplete="new-password" minLength={12} required autoFocus /></Field>
<Field id="repeat" label="Passwort wiederholen"><input id="repeat" name="repeat" type="password" autoComplete="new-password" minLength={12} required /></Field>
<button className="btn primary" type="submit">Passwort speichern</button></form>)}
</div></main>;
}
export default function Page() { return <Suspense><Form /></Suspense>; }

View file

@ -0,0 +1,14 @@
'use client';
import { useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { Alert, Field } from '@/components/ui';
export default function Forgot() {
const [done, setDone] = useState(false); const [err, setErr] = useState('');
async function submit(e: FormEvent<HTMLFormElement>) { e.preventDefault(); setErr(''); try { await api('POST', '/auth/password/forgot', { email: new FormData(e.currentTarget).get('email') }); setDone(true); } catch (x) { setErr(errMsg(x)); } }
return <main id="main" className="center"><div className="auth card"><h1>Passwort zurücksetzen</h1>
{done ? <Alert kind="ok">Falls ein Konto zu dieser Adresse existiert, wurde eine E-Mail mit einem Link gesendet.</Alert> : <form onSubmit={submit}>
{err && <Alert kind="err">{err}</Alert>}
<Field id="email" label="E-Mail"><input id="email" name="email" type="email" required autoFocus /></Field><button className="btn primary" type="submit">Link anfordern</button></form>}
<p className="small" style={{ marginTop: 16 }}><Link href="/login">Zurück zur Anmeldung</Link></p></div></main>;
}

View file

@ -0,0 +1,50 @@
'use client';
import { useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { copyText } from '@/lib/clipboard';
import { Alert, Field, fmt } from '@/components/ui';
export interface EncryptionInfo { mode: 'password' | 'age' | 'none'; passwordSet: boolean; version: number; updatedAt: string | null; minLength: number }
const ALPHABET = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_.!?#%+=';
/** Zufallspasswort mit dem Zufallsgenerator des Browsers (24 Zeichen, ohne verwechselbare Zeichen). */
function generate(len = 24): string { const b = new Uint32Array(len); crypto.getRandomValues(b); return Array.from(b, (n) => ALPHABET[n % ALPHABET.length]).join(''); }
export function BackupPassword({ info, canEdit, onChanged }: { info: EncryptionInfo; canEdit: boolean; onChanged: (autoBackup: boolean) => void }) {
const [pw, setPw] = useState(''); const [rep, setRep] = useState(''); const [cur, setCur] = useState(''); const [noted, setNoted] = useState(false); const [autoBackup, setAutoBackup] = useState(true);
const [show, setShow] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false);
const min = info.minLength; const ok = pw.length >= min;
async function submit(e: FormEvent) {
e.preventDefault(); setMsg(null);
if (pw !== rep) { setMsg({ k: 'err', t: 'Die beiden Passwörter stimmen nicht überein. Bitte prüfen Sie Ihre Eingabe.' }); return; }
setBusy(true);
try { const r = await api<{ version: number }>('PUT', '/admin/backup/password', { password: pw, repeat: rep, currentPassword: cur });
setMsg({ k: 'ok', t: `Das Backup-Passwort wurde gespeichert (Version ${r.version}).${autoBackup ? ' Eine neue Sicherung wird jetzt erstellt.' : ' Bis zur nächsten Sicherung gilt für neue Backups das neue Passwort.'}` });
setPw(''); setRep(''); setCur(''); setNoted(false); setShow(false); onChanged(autoBackup); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
return (
<div className="card"><h3>Verschlüsselung</h3>
<p>{info.passwordSet ? <span className="badge ok"><span aria-hidden="true">✓</span>Backup-Passwort gesetzt</span> : info.mode === 'age' ? <span className="badge warn"><span aria-hidden="true">▲</span>Schlüsseldatei (ohne eigenes Passwort)</span> : <span className="badge err"><span aria-hidden="true">✕</span>Nicht verschlüsselt eingerichtet</span>}</p>
<p className="small muted">{info.passwordSet ? `Version ${info.version}, zuletzt geändert ${info.updatedAt ? fmt(info.updatedAt) : '–'}. ` : ''}Alle Sicherungen werden mit AES-256 verschlüsselt. {info.passwordSet ? 'Zum Entschlüsseln genügt das Passwort (Standardwerkzeug gpg).' : 'Legen Sie ein eigenes Passwort fest, dann brauchen Sie im Notfall nur dieses.'}</p>
{!canEdit ? <p className="small muted">Das Passwort können nur Superadministratoren ändern.</p> : (
<form onSubmit={submit} style={{ marginTop: 16 }}>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<Alert kind="warn"><strong>Es gibt keine Möglichkeit, dieses Passwort wiederherzustellen.</strong> Ohne Passwort sind alle Sicherungen unlesbar. Bewahren Sie es sicher auf (Passwortmanager). Nach einer Änderung bleiben ältere Sicherungen mit dem <em>alten</em> Passwort verschlüsselt: bewahren Sie es auf, bis diese durch die Aufbewahrungsfrist verschwunden sind.</Alert>
<div className="cols">
<Field id="bp-new" label={info.passwordSet ? 'Neues Backup-Passwort' : 'Backup-Passwort'} hint={`Mindestens ${min} Zeichen (länger als 10).`}><input id="bp-new" type={show ? 'text' : 'password'} autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} required minLength={min} maxLength={200} /></Field>
<Field id="bp-rep" label="Passwort wiederholen"><input id="bp-rep" type={show ? 'text' : 'password'} autoComplete="new-password" value={rep} onChange={(e) => setRep(e.target.value)} required minLength={min} maxLength={200} /></Field>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<span className={`badge ${ok ? 'ok' : 'warn'}`}><span aria-hidden="true">{ok ? '✓' : '▲'}</span>{pw.length} Zeichen{ok ? '' : ` (noch ${min - pw.length} bis zum Minimum)`}</span>
<button type="button" className="btn small" onClick={() => { const g = generate(); setPw(g); setRep(g); setShow(true); setCopied(false); }}>Sicheres Passwort erzeugen</button>
<button type="button" className="btn small" onClick={() => setShow(!show)} aria-pressed={show}>{show ? 'Verbergen' : 'Anzeigen'}</button>
{pw && <button type="button" className="btn small" onClick={async () => setCopied(await copyText(pw))}>{copied ? 'Kopiert ✓' : 'Kopieren'}</button>}
</div>
<Field id="bp-cur" label="Zur Bestätigung: Ihr Anmeldepasswort"><input id="bp-cur" type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} required maxLength={200} /></Field>
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={noted} onChange={(e) => setNoted(e.target.checked)} />Ich habe das Passwort sicher notiert. Ohne Passwort kann ich keine Sicherung wiederherstellen.</label>
<label className="row small"><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={autoBackup} onChange={(e) => setAutoBackup(e.target.checked)} />Danach direkt eine neue Sicherung erstellen</label>
<div className="row" style={{ marginTop: 12 }}><button className="btn primary" type="submit" disabled={busy || !ok || !noted || !cur}>{busy ? 'Bitte warten …' : info.passwordSet ? 'Passwort ändern' : 'Passwort festlegen'}</button></div>
</form>)}
</div>
);
}

View file

@ -0,0 +1,65 @@
'use client';
import { useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { Alert, Empty, Field, fmt } from '@/components/ui';
export interface TargetInfo { id: string; name: string; type: 'sftp' | 'ftp' | 'gdrive' | 'local'; path: string; enabled: boolean; config: Record<string, any>; hasSecret: boolean; hostKeyPinned: boolean; hostKeyFingerprint: string | null; lastTestAt: string | null; lastTestOk: boolean | null; lastTestError: string | null }
const TYPE: Record<string, string> = { sftp: 'SFTP', ftp: 'FTP / FTPS', gdrive: 'Google Drive', local: 'Ordner / Netzlaufwerk' };
const where = (t: TargetInfo) => (t.type === 'sftp' || t.type === 'ftp' ? `${t.config.user}@${t.config.host}:${t.config.port}/${t.path}` : t.type === 'gdrive' ? `Drive:/${t.path}` : t.path);
export function BackupTargets({ targets, canEdit, onChanged }: { targets: TargetInfo[]; canEdit: boolean; onChanged: () => void }) {
const [open, setOpen] = useState(false); const [type, setType] = useState<TargetInfo['type']>('sftp'); const [auth, setAuth] = useState<'password' | 'key'>('password');
const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [busy, setBusy] = useState(''); const [del, setDel] = useState<string | null>(null);
async function test(id: string, name: string) {
setBusy(id);
try { const r = await api<{ ok: boolean; error: string | null; fingerprint: string | null; newHostKey: boolean }>('POST', `/admin/backup/targets/${id}/test`);
setMsg(r.ok ? { k: 'ok', t: `„${name}“: Verbindung funktioniert, Schreiben und Löschen einer Testdatei war erfolgreich.${r.newHostKey && r.fingerprint ? ` Server-Schlüssel gemerkt (${r.fingerprint}). Bitte mit dem Fingerabdruck Ihres Servers abgleichen; bei einer Abweichung schlägt der Upload künftig fehl.` : ''}` } : { k: 'err', t: `„${name}“: ${r.error ?? 'Verbindung fehlgeschlagen.'}` });
onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); }
}
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setMsg(null); const f = new FormData(e.currentTarget); const v = (k: string) => (f.get(k) as string) ?? '';
const base: Record<string, unknown> = { type, name: v('name'), path: v('path') };
if (type === 'sftp') Object.assign(base, { host: v('host'), port: Number(v('port') || 22), user: v('user'), authType: auth, ...(auth === 'password' ? { password: v('password') } : { privateKey: v('privateKey'), keyPassphrase: v('keyPassphrase') || undefined }) });
if (type === 'ftp') Object.assign(base, { host: v('host'), port: Number(v('port') || 21), user: v('user'), password: v('password'), tls: v('tls') });
if (type === 'gdrive') Object.assign(base, { token: v('token'), scope: v('scope') || 'drive.file', clientId: v('clientId') || undefined, clientSecret: v('clientSecret') || undefined });
setBusy('new');
try { const r = await api<{ id: string }>('POST', '/admin/backup/targets', base); setOpen(false); onChanged(); await test(r.id, v('name')); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(''); }
}
async function toggle(t: TargetInfo) { try { await api('PATCH', `/admin/backup/targets/${t.id}`, { enabled: !t.enabled }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function remove(id: string) { try { await api('DELETE', `/admin/backup/targets/${id}`); setDel(null); setMsg({ k: 'ok', t: 'Ziel entfernt. Bereits dort abgelegte Sicherungen bleiben unverändert.' }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function resetKey(id: string) { try { await api('POST', `/admin/backup/targets/${id}/reset-hostkey`); setMsg({ k: 'ok', t: 'Server-Schlüssel vergessen. Beim nächsten Test wird der aktuelle gemerkt.' }); onChanged(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
return (
<div className="card">
<div className="row between"><h3 style={{ margin: 0 }}>Externe Ziele</h3>{canEdit && <button className="btn primary" aria-expanded={open} onClick={() => setOpen(!open)}>Ziel hinzufügen</button>}</div>
{msg && <div style={{ marginTop: 12 }}><Alert kind={msg.k}>{msg.t}</Alert></div>}
{targets.length === 0 ? <Empty title="Noch kein externes Ziel">Ohne externes Ziel liegen die Sicherungen nur auf diesem Server.</Empty> : (
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Typ und Ziel</th><th>Status</th>{canEdit && <th><span className="sr">Aktionen</span></th>}</tr></thead><tbody>
{targets.map((t) => <tr key={t.id}><td>{t.name}{!t.enabled && <div><span className="badge lock"><span aria-hidden="true">⊘</span>Deaktiviert</span></div>}</td>
<td><div>{TYPE[t.type]}</div><div className="small muted mono">{where(t)}</div>{t.type === 'sftp' && (t.hostKeyPinned ? <div className="small muted">Server-Schlüssel: <span className="mono">{t.hostKeyFingerprint ?? 'gemerkt'}</span></div> : <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>Server-Schlüssel noch nicht gemerkt (beim Test)</span></div>)}</td>
<td>{t.lastTestOk === null ? <span className="badge info"><span aria-hidden="true">○</span>Noch nicht getestet</span> : t.lastTestOk ? <span className="badge ok"><span aria-hidden="true">✓</span>Funktioniert</span> : <span className="badge err"><span aria-hidden="true">✕</span>Fehler</span>}
{t.lastTestAt && <div className="small muted">{fmt(t.lastTestAt)}</div>}{t.lastTestOk === false && t.lastTestError && <div className="small" style={{ color: 'var(--err)' }}>{t.lastTestError}</div>}</td>
{canEdit && <td>{del === t.id ? <div role="alertdialog" aria-label="Ziel entfernen"><p className="small" style={{ margin: '0 0 8px' }}>Ziel „{t.name}“ entfernen? Bereits abgelegte Sicherungen bleiben dort.</p><div className="row"><button className="btn small danger" onClick={() => remove(t.id)}>Ja, entfernen</button><button className="btn small" onClick={() => setDel(null)}>Abbrechen</button></div></div>
: <div className="row" style={{ gap: 8 }}><button className="btn small" disabled={busy === t.id} onClick={() => test(t.id, t.name)}>{busy === t.id ? 'Teste …' : 'Testen'}</button><button className="btn small" onClick={() => toggle(t)}>{t.enabled ? 'Deaktivieren' : 'Aktivieren'}</button>
{t.type === 'sftp' && t.hostKeyPinned && <button className="btn small" onClick={() => resetKey(t.id)}>Server-Schlüssel neu erfassen</button>}<button className="btn small danger" onClick={() => setDel(t.id)}>Entfernen</button></div>}</td>}</tr>)}</tbody></table></div>)}
{open && canEdit && <form onSubmit={create} style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<h4 style={{ margin: '0 0 12px' }}>Neues Ziel</h4>
<div className="cols"><Field id="t-type" label="Art des Ziels"><select id="t-type" value={type} onChange={(e) => setType(e.target.value as TargetInfo['type'])}><option value="sftp">SFTP (per SSH)</option><option value="ftp">FTP / FTPS</option><option value="gdrive">Google Drive</option><option value="local">Ordner / Netzlaufwerk (eingehängt)</option></select></Field>
<Field id="t-name" label="Name"><input id="t-name" name="name" required maxLength={100} placeholder="z. B. NAS im Keller" /></Field></div>
{(type === 'sftp' || type === 'ftp') && <div className="cols"><Field id="t-host" label="Server"><input id="t-host" name="host" required placeholder="nas.example.de oder 192.168.10.20" /></Field><Field id="t-port" label="Port"><input id="t-port" name="port" type="number" min={1} max={65535} defaultValue={type === 'sftp' ? 22 : 21} /></Field><Field id="t-user" label="Benutzer"><input id="t-user" name="user" required autoComplete="off" /></Field></div>}
{type === 'sftp' && <><fieldset style={{ border: 0, padding: 0, margin: '0 0 12px' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 6 }}>Anmeldung</legend><div className="row"><label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={auth === 'password'} onChange={() => setAuth('password')} />Passwort</label><label className="row"><input type="radio" style={{ width: 20, minHeight: 20 }} checked={auth === 'key'} onChange={() => setAuth('key')} />Privater Schlüssel (empfohlen)</label></div></fieldset>
{auth === 'password' ? <Field id="t-pw" label="Passwort" hint="Wird verschlüsselt gespeichert und nie wieder angezeigt."><input id="t-pw" name="password" type="password" autoComplete="new-password" required /></Field>
: <div className="cols"><Field id="t-key" label="Privater Schlüssel (PEM)" hint="Beginnt mit -----BEGIN … PRIVATE KEY-----. Der passende öffentliche Schlüssel muss auf dem Server hinterlegt sein."><textarea id="t-key" name="privateKey" rows={5} required spellCheck={false} style={{ width: '100%', font: 'inherit', padding: 10, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', color: 'var(--text)' }} /></Field><Field id="t-kp" label="Passphrase des Schlüssels (falls vorhanden)"><input id="t-kp" name="keyPassphrase" type="password" autoComplete="new-password" /></Field></div>}
<p className="small muted">Der Server-Schlüssel wird beim ersten Test gemerkt (Fingerabdruck wird angezeigt). Danach wird ein anderer Server abgelehnt.</p></>}
{type === 'ftp' && <div className="cols"><Field id="t-fpw" label="Passwort" hint="Wird verschlüsselt gespeichert."><input id="t-fpw" name="password" type="password" autoComplete="new-password" required /></Field>
<Field id="t-tls" label="Verschlüsselung der Verbindung"><select id="t-tls" name="tls" defaultValue="explicit"><option value="explicit">FTPS (explizit, empfohlen)</option><option value="implicit">FTPS (implizit)</option><option value="none">Keine (Passwort im Klartext im Netz!)</option></select></Field></div>}
{type === 'gdrive' && <><Alert kind="info">Google Drive braucht eine einmalige Freigabe. Führen Sie auf einem Rechner mit Browser diesen Befehl aus (rclone installiert) und fügen Sie den ausgegebenen Text (<span className="mono">{'{"access_token":…}'}</span>) unten ein:<br /><span className="mono" style={{ userSelect: 'all' }}>rclone authorize &quot;drive&quot; &quot;eyJzY29wZSI6ImRyaXZlLmZpbGUifQ&quot;</span><br />Es wird nur der Zugriff auf von rclone angelegte Dateien angefragt (Bereich drive.file).</Alert>
<Field id="t-tok" label="Token (JSON)" hint="Wird verschlüsselt gespeichert und nie wieder angezeigt."><textarea id="t-tok" name="token" rows={4} required spellCheck={false} style={{ width: '100%', font: 'inherit', padding: 10, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface)', color: 'var(--text)' }} /></Field>
<div className="cols"><Field id="t-cid" label="Eigene Client-ID (optional, empfohlen)"><input id="t-cid" name="clientId" autoComplete="off" /></Field><Field id="t-cs" label="Client-Secret (optional)"><input id="t-cs" name="clientSecret" type="password" autoComplete="new-password" /></Field></div></>}
<Field id="t-path" label={type === 'local' ? 'Ordner (absoluter Pfad)' : 'Ordner auf dem Ziel'} hint={type === 'local' ? 'Der Ordner muss existieren, z. B. ein eingehängtes Netzlaufwerk unter /mnt/nas.' : 'Wird bei Bedarf angelegt, z. B. Backups/kundencenter'}><input id="t-path" name="path" required={type === 'local'} maxLength={400} placeholder={type === 'local' ? '/mnt/nas/kundencenter' : 'Backups/kundencenter'} /></Field>
<div className="row"><button className="btn primary" type="submit" disabled={busy === 'new'}>{busy === 'new' ? 'Bitte warten …' : 'Speichern und testen'}</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div>
</form>}
</div>
);
}

View file

@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { api, errMsg } from '@/lib/api';
import { Field } from '@/components/ui';
export interface CfgField { name: string; label: string; secret: boolean; required: boolean; placeholder: string; help: string; advanced: boolean; options: { value: string; label: string }[] | null }
/** Formularfelder eines Connectors: Pflichtfelder sichtbar, „erweiterte“ Einstellungen eingeklappt, Auswahlfelder statt Freitext. */
export function ConfigFields({ fields, prefix, existing, hasSecrets }: { fields: CfgField[]; prefix: string; existing?: Record<string, unknown>; hasSecrets?: boolean }) {
const [tls, setTls] = useState<{ ok: boolean; text: string } | null>(null); const [busy, setBusy] = useState(false);
const cur = (n: string) => { const v = existing?.[n]; return v === undefined || v === null ? '' : String(v); };
async function fetchFp(fieldId: string) {
const baseEl = document.getElementById(`${prefix}baseUrl`) as HTMLInputElement | null; const url = baseEl?.value?.trim() || cur('baseUrl');
if (!url) { setTls({ ok: false, text: 'Bitte zuerst die Adresse eintragen.' }); return; }
setBusy(true); setTls(null);
try {
const r = await api<{ fingerprint: string; subject: string | null; issuer: string | null; validTo: string | null; trusted: boolean; selfSigned: boolean }>('POST', '/admin/connector-tls-check', { url });
if (r.trusted) { setTls({ ok: true, text: `Das Zertifikat von ${r.subject ?? 'dem Server'} ist von einer öffentlichen Stelle bestätigt. Ein Fingerabdruck ist nicht nötig, das Feld kann leer bleiben.` }); return; }
(document.getElementById(fieldId) as HTMLInputElement).value = r.fingerprint;
setTls({ ok: true, text: `Fingerabdruck eingetragen (Zertifikat für ${r.subject ?? '?'}${r.selfSigned ? ', selbstsigniert' : ''}, gültig bis ${r.validTo ?? '?'}). Bitte einmal mit dem Zertifikat in KeyHelp abgleichen, dann speichern.` });
} catch (e) { setTls({ ok: false, text: errMsg(e) }); } finally { setBusy(false); }
}
const one = (f: CfgField) => {
const id = `${prefix}${f.name}`; const hint = [f.help, f.secret ? (hasSecrets ? 'Gespeichert. Nur neu eingeben, um zu ersetzen.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.') : cur(f.name) && !f.options ? `Aktuell: ${cur(f.name)}` : ''].filter(Boolean).join(' ');
return (<div key={f.name}>
<Field id={id} label={f.label + (f.required && !existing ? ' *' : '')} hint={hint || undefined}>
{f.options ? <select id={id} name={f.name} defaultValue={cur(f.name) || f.options[0]!.value}>{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
: <input id={id} name={f.name} type={f.secret ? 'password' : 'text'} autoComplete="off" placeholder={f.placeholder} required={f.required && !existing} defaultValue={f.secret ? undefined : undefined} />}
</Field>
{f.name === 'tlsFingerprint' && <div className="row" style={{ marginTop: -8, marginBottom: 12 }}><button type="button" className="btn small" disabled={busy} onClick={() => fetchFp(id)}>{busy ? 'Wird geholt …' : 'Vom Server holen'}</button></div>}
</div>);
};
const main = fields.filter((f) => !f.advanced); const adv = fields.filter((f) => f.advanced);
return (<>
<div className="cols">{main.map(one)}</div>
{adv.length > 0 && <details style={{ margin: '8px 0 16px' }}><summary style={{ cursor: 'pointer', fontWeight: 600 }}>Erweiterte Einstellungen (optional)</summary>
<div style={{ paddingTop: 12 }}><div className="cols">{adv.map(one)}</div>
{tls && <div role="status" className={`alert ${tls.ok ? 'ok' : 'err'}`}>{tls.text}</div>}</div></details>}
</>);
}

View file

@ -0,0 +1,51 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, eur, fmt } from '@/components/ui';
interface Rec { id: string; domain: string; tld: string; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; orderedRef: string | null; note: string | null }
interface Res { id: string; name: string; type: string; orgId: string | null }
const PROC = { open: 'Bei KCS zu bestellen', ordered: 'Bei KCS bestellt', external: 'Anderer Anbieter' } as const;
const money = (c: number | null) => c === null ? '–' : eur(c);
const csvNum = (c: number | null) => c === null ? '' : (c / 100).toFixed(2).replace('.', ',');
const csvCell = (v: string | number | null) => `"${String(v ?? '').replace(/"/g, '""')}"`;
/** CSV für Excel (Semikolon, UTF-8 mit BOM): Bestellliste bzw. Gesamtaufstellung. */
function download(rows: Rec[], name: string) {
const head = ['Domain', 'Endung', 'Laufzeit (Monate)', 'Kunde', 'Kundennr.', 'EK brutto', 'EK netto', 'Setup EK brutto', 'VK brutto', 'VK netto', 'USt %', 'Gewinn netto', 'Status', 'bestellt am', 'Bestellnr. KCS', 'Notiz'];
const lines = rows.map((r) => [r.domain, r.tld, r.termMonths, r.customer, r.customerNumber, csvNum(r.costGrossCents), csvNum(r.costNetCents), csvNum(r.setupCostCents), csvNum(r.sellGrossCents), csvNum(r.sellNetCents), r.taxBp / 100, csvNum(r.profitNetCents), PROC[r.procurement], r.orderedAt ? new Date(r.orderedAt).toLocaleDateString('de-DE') : '', r.orderedRef, r.note].map(csvCell).join(';'));
const url = URL.createObjectURL(new Blob(['' + [head.map(csvCell).join(';'), ...lines].join('\r\n')], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
export function DomainRecords({ orgId }: { orgId?: string }) {
const { can } = useSession(); const w = can('domains.write');
const [list, setList] = useState<Rec[] | null>(null); const [status, setStatus] = useState(''); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const [res, setRes] = useState<Res[]>([]); const [ref, setRef] = useState<Record<string, string>>({});
const load = useCallback(() => api<Rec[]>('GET', `/admin/domain-records?${new URLSearchParams({ ...(status ? { status } : {}), ...(q ? { q } : {}), ...(orgId ? { orgId } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [status, q, orgId]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
useEffect(() => { if (w) api<Res[]>('GET', '/resources').then((r) => setRes(r.filter((x) => x.type === 'hosting_account' && (!orgId || x.orgId === orgId)))).catch(() => undefined); }, [w, orgId]);
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } };
async function add(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = e.currentTarget; const d = String(new FormData(f).get('domain')); await run(() => api('POST', '/admin/domain-records', { domain: d, orgId: orgId ?? null }), `${d} erfasst.`); f.reset(); }
async function fromRes(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const id = String(new FormData(e.currentTarget).get('res')); if (!id) return; await run(async () => { const r = await api<{ added: number; skipped: number; unpriced: number }>('POST', `/admin/domain-records/from-resource/${id}`); setOk(`${r.added} Domains übernommen, ${r.skipped} übersprungen (Subdomains, System-Domain, bereits erfasst)${r.unpriced ? `. Bei ${r.unpriced} fehlt die Endung in der Preisliste.` : '.'}`); }); }
const sum = (f: (r: Rec) => number | null) => (list ?? []).reduce((s, r) => s + (f(r) ?? 0), 0);
const open = (list ?? []).filter((r) => r.procurement === 'open');
return (<>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{w && <div className="card"><div className="cols">
<form onSubmit={add}><Field id={`nd${orgId ?? ''}`} label="Domain manuell erfassen"><input id={`nd${orgId ?? ''}`} name="domain" placeholder="beispiel.de" required autoComplete="off" /></Field><button className="btn primary" type="submit">Erfassen</button></form>
<form onSubmit={fromRes}><Field id={`nr${orgId ?? ''}`} label="Domains eines Hosting-Kontos übernehmen" hint="Liest die Domains aus KeyHelp (ohne Subdomains)."><select id={`nr${orgId ?? ''}`} name="res" defaultValue=""><option value="">Konto wählen …</option>{res.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select></Field><button className="btn" type="submit">Übernehmen</button></form></div></div>}
<div className="card">
<div className="cols"><Field id={`q${orgId ?? ''}`} label="Suche"><input id={`q${orgId ?? ''}`} type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Domain" /></Field>
<Field id={`st${orgId ?? ''}`} label="Status"><select id={`st${orgId ?? ''}`} value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Noch keine Domains erfasst">Erfassen Sie Domains manuell oder übernehmen Sie sie aus einem Hosting-Konto.</Empty> : <>
<div className="tablewrap"><table><thead><tr><th>Domain</th>{!orgId && <th>Kunde</th>}<th>EK brutto</th><th>EK netto</th><th>VK brutto</th><th>VK netto</th><th>Gewinn netto</th><th>Status bei KCS</th>{w && <th></th>}</tr></thead><tbody>
{list.map((r) => (<tr key={r.id}><td><span className="mono">{r.domain}</span>{r.termMonths && <div className="muted small">{r.termMonths} Mon.{r.setupCostCents ? ` · Setup ${eur(r.setupCostCents)}` : ''}</div>}{r.sellNetCents === null && <div className="small"><span className="badge warn"><span aria-hidden="true">▲</span>{r.costGrossCents === null ? 'Endung nicht in Preisliste' : 'Aufschlag fehlt'}</span></div>}</td>
{!orgId && <td>{r.customer ? <>{r.customer}<div className="muted small">{r.customerNumber}</div></> : <span className="muted small">–</span>}</td>}<td>{money(r.costGrossCents)}<div className="muted small">inkl. USt</div></td><td>{money(r.costNetCents)}</td><td><strong>{money(r.sellGrossCents)}</strong><div className="muted small">inkl. {r.taxBp / 100} % USt</div></td><td>{money(r.sellNetCents)}</td><td>{money(r.profitNetCents)}</td>
<td>{w ? <><select aria-label={`Status ${r.domain}`} value={r.procurement} onChange={(e) => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { procurement: e.target.value }))}>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select>
{r.procurement === 'ordered' && <div className="row" style={{ marginTop: 4 }}><input aria-label="Bestellnummer bei KCS" placeholder="Bestellnr." style={{ width: 120 }} value={ref[r.id] ?? r.orderedRef ?? ''} onChange={(e) => setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] !== undefined && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedRef: ref[r.id] || null }))} />{r.orderedAt && <span className="muted small">{fmt(r.orderedAt)}</span>}</div>}</> : PROC[r.procurement]}</td>
{w && <td><div className="row"><button className="btn small" title="Einkauf und Verkauf mit der aktuellen Preisliste neu berechnen" onClick={() => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { reprice: true }), `${r.domain} neu berechnet.`)}>Neu berechnen</button><button className="btn small" onClick={() => confirm(`${r.domain} aus der Aufstellung entfernen?`) && run(() => api('DELETE', `/admin/domain-records/${r.id}`))}>Entfernen</button></div></td>}</tr>))}
<tr><th>Summe ({list.length})</th>{!orgId && <th></th>}<th>{eur(sum((r) => r.costGrossCents))}</th><th>{eur(sum((r) => r.costNetCents))}</th><th>{eur(sum((r) => r.sellGrossCents))}</th><th>{eur(sum((r) => r.sellNetCents))}</th><th>{eur(sum((r) => r.profitNetCents))}</th><th colSpan={w ? 2 : 1}></th></tr></tbody></table></div>
<div className="row"><button className="btn primary" disabled={open.length === 0} onClick={() => download(open, `KCS-Bestellliste-${new Date().toISOString().slice(0, 10)}.csv`)}>Bestellliste für KCS (offen: {open.length})</button><button className="btn" onClick={() => download(list, `Domain-Aufstellung-${new Date().toISOString().slice(0, 10)}.csv`)}>Gesamte Aufstellung als CSV</button></div></>}
<p className="muted small">Summen beziehen sich auf die angezeigte Auswahl. Preise sind Momentaufnahmen; „Neu berechnen“ übernimmt geänderte Preislisten oder Aufschläge.</p></div>
</>);
}

View file

@ -0,0 +1,116 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { copyText } from '@/lib/clipboard';
import { generatePassword, MIN_CHILD_PASSWORD } from '@/lib/password';
import { Alert, Empty, Field, fmt } from '@/components/ui';
type Kind = 'domain' | 'email' | 'database' | 'ftp' | 'certificate';
interface Child { id: string; kind: Kind; name: string; state: string; validUntil?: string | null; details: Record<string, any> }
interface Kinds { kinds: { kind: Kind; canWrite: boolean }[]; panelLogin: boolean }
const LABEL: Record<Kind, string> = { domain: 'Domains', email: 'E-Mail', database: 'Datenbanken', ftp: 'FTP', certificate: 'SSL-Zertifikate' };
const STATE: Record<string, [string, string, string]> = { active: ['ok', '●', 'Aktiv'], disabled: ['lock', '⊘', 'Deaktiviert'], pending: ['info', '◐', 'Wird eingerichtet'], error: ['err', '✕', 'Fehler'], expired: ['warn', '▲', 'Abgelaufen'] };
const bytes = (b?: number | null) => (b == null ? '–' : b < 1024 ** 2 ? `${Math.max(0, Math.round(b / 1024))} KB` : b < 1024 ** 3 ? `${(b / 1024 ** 2).toFixed(1)} MB` : `${(b / 1024 ** 3).toFixed(1)} GB`);
const USAGE: Record<string, { label: string; bytes?: boolean }> = { disk_space: { label: 'Speicher', bytes: true }, traffic: { label: 'Traffic', bytes: true }, domains: { label: 'Domains' }, email_accounts: { label: 'Postfächer' }, databases: { label: 'Datenbanken' }, ftp_users: { label: 'FTP-Benutzer' }, subdomains: { label: 'Subdomains' } };
/** Nutzung als Balken (mit Text, nicht nur Farbe). */
export function UsageBars({ usage, limits }: { usage: Record<string, unknown>; limits: Record<string, unknown> }) {
const rows = Object.entries(USAGE).filter(([k]) => typeof usage[k] === 'number');
if (!rows.length) return null;
return (<div className="card"><h2>Nutzung</h2><div className="grid">{rows.map(([k, m]) => {
const v = usage[k] as number; const max = limits[k] as number | null | undefined; const unlimited = max === null || max === undefined; const pct = unlimited || !max ? 0 : Math.min(100, Math.round((v / max) * 100)); const warn = !unlimited && pct >= 90;
const f = (x: number) => (m.bytes ? bytes(x) : String(x));
return (<div key={k}><div className="row between small"><strong>{m.label}</strong><span>{f(v)} / {unlimited ? 'unbegrenzt' : f(max as number)}{warn && <> <span className="badge warn"><span aria-hidden="true">▲</span>fast voll</span></>}</span></div>
<div role="progressbar" aria-label={m.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={unlimited ? undefined : pct} aria-valuetext={unlimited ? 'unbegrenzt' : `${pct} Prozent`} style={{ height: 10, background: 'var(--surface-2)', borderRadius: 6, overflow: 'hidden', border: '1px solid var(--border)' }}><div style={{ width: `${unlimited ? 0 : pct}%`, height: '100%', background: warn ? 'var(--err)' : 'var(--accent)' }} /></div></div>);
})}</div></div>);
}
export function PanelLoginButton({ resourceId }: { resourceId: string }) {
const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [url, setUrl] = useState('');
async function go() {
setBusy(true); setErr(''); setUrl('');
try { const r = await api<{ url: string }>('POST', `/resources/${resourceId}/login`); const w = window.open(r.url, '_blank', 'noopener,noreferrer'); if (!w) setUrl(r.url); }
catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
}
return (<div><button className="btn primary" onClick={go} disabled={busy}>{busy ? 'Bitte warten …' : 'Kundenpanel öffnen'}</button>
{err && <div style={{ marginTop: 8 }}><Alert kind="err">{err}</Alert></div>}
{url && <p className="small" style={{ marginTop: 8 }}>Der Browser hat das Fenster blockiert: <a href={url} target="_blank" rel="noopener noreferrer">Jetzt öffnen</a> (Link 60 Minuten gültig).</p>}
<p className="small muted" style={{ marginTop: 8 }}>Sicherer Einmal-Link, 60 Minuten gültig. Jeder Aufruf wird protokolliert.</p></div>);
}
async function waitJob(id: string, onState: (s: string) => void): Promise<{ status: string; error: string | null }> {
for (let i = 0; i < 80; i++) {
const j = await api<{ status: string; error: string | null }>('GET', `/jobs/${id}`); onState(j.status);
if (['succeeded', 'failed', 'needs_review'].includes(j.status)) return j;
await new Promise((r) => setTimeout(r, 1500));
}
return { status: 'running', error: null };
}
const JOB_TEXT: Record<string, string> = { scheduled: 'Geplant …', running: 'Wird ausgeführt …', retrying: 'Wird wiederholt …', waiting_external: 'Wartet auf den Anbieter …' };
function PasswordField({ id, value, onChange, label = 'Passwort' }: { id: string; value: string; onChange: (v: string) => void; label?: string }) {
const [show, setShow] = useState(false); const [copied, setCopied] = useState(false);
return (<div><Field id={id} label={label} hint={`Mindestens ${MIN_CHILD_PASSWORD} Zeichen. Wird nur zur Ausführung verschlüsselt zwischengespeichert.`}><input id={id} type={show ? 'text' : 'password'} value={value} onChange={(e) => onChange(e.target.value)} autoComplete="new-password" minLength={MIN_CHILD_PASSWORD} maxLength={128} required /></Field>
<div className="row" style={{ marginBottom: 12 }}><button type="button" className="btn small" onClick={() => { onChange(generatePassword()); setShow(true); setCopied(false); }}>Sicheres Passwort erzeugen</button>
<button type="button" className="btn small" onClick={() => setShow(!show)} aria-pressed={show}>{show ? 'Verbergen' : 'Anzeigen'}</button>
{value && <button type="button" className="btn small" onClick={async () => setCopied(await copyText(value))}>{copied ? 'Kopiert ✓' : 'Kopieren'}</button>}</div></div>);
}
export function HostingPanel({ resourceId, canLogin }: { resourceId: string; canLogin: boolean }) {
const [kinds, setKinds] = useState<Kinds | null>(null); const [kind, setKind] = useState<Kind>('domain'); const [rows, setRows] = useState<Child[] | null>(null); const [err, setErr] = useState('');
const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'info'; t: string } | null>(null); const [adding, setAdding] = useState(false); const [del, setDel] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [pw, setPw] = useState('');
const [domains, setDomains] = useState<Child[]>([]);
useEffect(() => { api<Kinds>('GET', `/resources/${resourceId}/children`).then((k) => { setKinds(k); if (k.kinds[0]) setKind(k.kinds[0].kind); }).catch((e) => setErr(errMsg(e))); }, [resourceId]);
const load = useCallback(() => { setRows(null); setErr(''); api<Child[]>('GET', `/resources/${resourceId}/children/${kind}`).then(setRows).catch((e) => { setErr(errMsg(e)); setRows([]); }); }, [resourceId, kind]);
useEffect(() => { if (kinds) load(); }, [kinds, load]);
useEffect(() => { api<Child[]>('GET', `/resources/${resourceId}/children/domain`).then(setDomains).catch(() => setDomains([])); }, [resourceId]);
const canWrite = kinds?.kinds.find((k) => k.kind === kind)?.canWrite ?? false;
async function submit(op: 'create' | 'update' | 'delete', data: Record<string, unknown>, opts: { id?: string; password?: string; okText: string }) {
setBusy(true); setMsg(null);
try {
const r = await api<{ jobId: string }>('POST', `/resources/${resourceId}/children/${kind}`, { op, id: opts.id, data, password: opts.password });
setMsg({ k: 'info', t: 'Der Auftrag wurde angenommen …' });
const j = await waitJob(r.jobId, (s) => setMsg({ k: 'info', t: JOB_TEXT[s] ?? 'Wird ausgeführt …' }));
if (j.status === 'succeeded') { setMsg({ k: 'ok', t: opts.okText }); setAdding(false); setDel(null); setPw(''); load(); }
else if (j.status === 'running') setMsg({ k: 'info', t: 'Der Vorgang läuft noch. Bitte in einigen Sekunden neu laden.' });
else setMsg({ k: 'err', t: j.error ?? 'Der Vorgang ist fehlgeschlagen.' });
} catch (e) { setMsg({ k: 'err', t: errMsg(e) }); } finally { setBusy(false); }
}
function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const g = (n: string) => ((f.get(n) as string) || '').trim();
if (kind === 'domain') return submit('create', { domain: g('domain'), ...(g('php') ? { phpVersion: g('php') } : {}), letsEncrypt: f.get('le') === 'on', forceHttps: f.get('le') === 'on' }, { okText: 'Die Domain wurde angelegt.' });
if (kind === 'email') return submit('create', { local: g('local'), domain: g('mdomain'), ...(g('size') ? { maxSizeBytes: Math.round(Number(g('size')) * 1024 * 1024) } : {}) }, { password: pw, okText: 'Das Postfach wurde angelegt.' });
if (kind === 'database') return submit('create', { ...(g('name') ? { name: g('name') } : {}), description: g('desc') }, { password: pw, okText: 'Die Datenbank wurde angelegt.' });
if (kind === 'ftp') return submit('create', { ...(g('user') ? { username: g('user') } : {}), home: g('home') || '/www/', description: g('desc') }, { password: pw, okText: 'Der FTP-Zugang wurde angelegt.' });
}
if (!kinds) return err ? <Alert kind="err">{err}</Alert> : null;
return (<>
{canLogin && <div className="card"><h2>Kundenpanel</h2><PanelLoginButton resourceId={resourceId} /></div>}
<div className="card"><h2>Hosting-Ressourcen</h2>
<div role="tablist" aria-label="Ressourcenarten" className="row" style={{ gap: 8, marginBottom: 16 }}>
{kinds.kinds.map((k) => <button key={k.kind} role="tab" aria-selected={kind === k.kind} className="btn" style={kind === k.kind ? { background: 'var(--surface-2)', fontWeight: 600, borderColor: 'var(--accent)' } : undefined} onClick={() => { setKind(k.kind); setAdding(false); setMsg(null); setDel(null); }}>{LABEL[k.kind]}</button>)}</div>
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
{err && <Alert kind="err">{err}</Alert>}
{canWrite && kind !== 'certificate' && <div style={{ marginBottom: 12 }}><button className="btn primary" aria-expanded={adding} onClick={() => { setAdding(!adding); setPw(''); }}>{{ domain: 'Domain hinzufügen', email: 'Postfach anlegen', database: 'Datenbank anlegen', ftp: 'FTP-Zugang anlegen', certificate: '' }[kind]}</button></div>}
{adding && canWrite && <form onSubmit={create} style={{ borderBottom: '1px solid var(--border)', marginBottom: 16, paddingBottom: 8 }}>
{kind === 'domain' && <div className="cols"><Field id="hp-dom" label="Domainname"><input id="hp-dom" name="domain" required placeholder="beispiel.de oder www.beispiel.de" /></Field><Field id="hp-php" label="PHP-Version (leer = Standard)"><input id="hp-php" name="php" placeholder="z. B. 8.3" /></Field><label className="row" style={{ alignSelf: 'end', minHeight: 44 }}><input type="checkbox" name="le" defaultChecked style={{ width: 20, minHeight: 20 }} />Let’s Encrypt und HTTPS</label></div>}
{kind === 'email' && <><div className="cols"><Field id="hp-loc" label="Postfach"><input id="hp-loc" name="local" required placeholder="info" /></Field><Field id="hp-md" label="Domain"><select id="hp-md" name="mdomain" required>{domains.filter((d) => d.details.emailDomain || true).map((d) => <option key={d.id} value={d.name}>{d.name}</option>)}</select></Field><Field id="hp-sz" label="Größe in MB (optional)"><input id="hp-sz" name="size" type="number" min={0} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
{kind === 'database' && <><div className="cols"><Field id="hp-dn" label="Name (optional, sonst automatisch)" hint="Kleinbuchstaben, Ziffern, Unterstrich"><input id="hp-dn" name="name" pattern="[a-z0-9_]{1,64}" /></Field><Field id="hp-dd" label="Beschreibung"><input id="hp-dd" name="desc" maxLength={200} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
{kind === 'ftp' && <><div className="cols"><Field id="hp-fu" label="Benutzername (optional, sonst automatisch)"><input id="hp-fu" name="user" pattern="[A-Za-z0-9._-]{1,32}" /></Field><Field id="hp-fh" label="Verzeichnis" hint="Beginnt mit /www/ oder /files"><input id="hp-fh" name="home" defaultValue="/www/" /></Field><Field id="hp-fd" label="Beschreibung"><input id="hp-fd" name="desc" maxLength={200} /></Field></div><PasswordField id="hp-pw" value={pw} onChange={setPw} /></>}
<div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Anlegen'}</button><button className="btn" type="button" onClick={() => setAdding(false)}>Abbrechen</button></div></form>}
{rows === null ? <p className="muted" role="status">Wird beim Anbieter gelesen …</p> : rows.length === 0 && !err ? <Empty title="Keine Einträge" /> : (
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Details</th><th>Status</th>{canWrite && <th><span className="sr">Aktionen</span></th>}</tr></thead><tbody>
{rows.map((r) => { const [c, i, l] = STATE[r.state] ?? ['info', '○', r.state]; const d = r.details; return (<tr key={r.id}>
<td className="mono">{r.name}</td>
<td className="small">{r.kind === 'domain' && <>{d.subdomain ? 'Subdomain' : d.system ? 'System-Domain' : 'Hauptdomain'} · PHP {d.phpVersion} · {d.letsEncrypt ? 'Let’s Encrypt' : 'kein Let’s Encrypt'}{d.forceHttps ? ' · HTTPS erzwungen' : ''}</>}
{r.kind === 'email' && <>{bytes(d.sizeBytes)} von {d.maxSizeBytes ? bytes(d.maxSizeBytes) : 'unbegrenzt'}{d.forwardings?.length ? ` · Weiterleitung an ${d.forwardings.join(', ')}` : ''}</>}
{r.kind === 'database' && <>Benutzer <span className="mono">{d.user}</span> · {bytes(d.sizeBytes)}</>}
{r.kind === 'ftp' && <>Verzeichnis <span className="mono">{d.home}</span></>}
{r.kind === 'certificate' && <>{d.issuer ?? '–'} · gültig bis {r.validUntil ? fmt(r.validUntil) : '–'} · {(d.securedDomains ?? []).join(', ')}</>}</td>
<td><span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span></td>
{canWrite && <td>{del === r.id ? <div role="alertdialog" aria-label="Löschen bestätigen"><p className="small" style={{ margin: '0 0 8px' }}><strong>„{r.name}“ endgültig löschen?</strong>{r.kind !== 'domain' ? ' Die Daten gehen verloren.' : ' Zugehörige Inhalte können verloren gehen.'}</p><div className="row"><button className="btn small danger" disabled={busy} onClick={() => submit('delete', {}, { id: r.id, okText: 'Wurde gelöscht.' })}>Ja, löschen</button><button className="btn small" onClick={() => setDel(null)}>Abbrechen</button></div></div>
: <div className="row" style={{ gap: 8 }}><button className="btn small danger" onClick={() => setDel(r.id)} disabled={busy || (r.kind === 'domain' && d.system)}>Löschen</button></div>}</td>}</tr>); })}</tbody></table></div>)}
</div>
</>);
}

View file

@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
import { api, errMsg } from '@/lib/api';
import { copyText } from '@/lib/clipboard';
/**
* Lizenzschlüssel: standardmäßig unscharf (im Seiteninhalt steht nur ein Platzhalter), Klick lädt den echten Schlüssel,
* daneben ein Kopieren-Button. Der Abruf erfolgt live beim Anbieter, wird protokolliert und nie gespeichert.
*/
export function SecretField({ resourceId, compact = false }: { resourceId: string; compact?: boolean }) {
const [secret, setSecret] = useState<string | null>(null); const [left, setLeft] = useState(0); const [copied, setCopied] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState('');
useEffect(() => { if (!secret) return; if (left <= 0) { setSecret(null); setCopied(false); return; } const t = setTimeout(() => setLeft(left - 1), 1000); return () => clearTimeout(t); }, [secret, left]);
const fetchSecret = () => api<{ items: { label: string; value: string }[]; hideAfterSec: number }>('POST', `/resources/${resourceId}/reveal`);
async function reveal() {
if (secret || busy) return; setBusy(true); setErr('');
try { const r = await fetchSecret(); setSecret(r.items[0]?.value ?? null); setLeft(r.hideAfterSec); setCopied(false); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
/** Kopieren nutzt den angezeigten Wert, sonst wird er kurz abgerufen und direkt kopiert, ohne ihn einzublenden. */
async function copy() {
if (busy) return; setBusy(true); setErr('');
let v: string | null | undefined = secret;
if (!v) { try { v = (await fetchSecret()).items[0]?.value; } catch (x) { setCopied(false); setErr(errMsg(x)); setBusy(false); return; } } // echter Abruffehler
if (!v) { setErr('Kein Schlüssel vorhanden.'); setBusy(false); return; }
const ok = await copyText(v);
setCopied(ok);
if (!ok) { setErr('Der Browser hat das Kopieren blockiert. Bitte auf den Schlüssel klicken (markiert ihn komplett) und mit Strg+C kopieren.'); if (!secret) { setSecret(v); setLeft(60); } } // Rückfall: zum manuellen Kopieren einblenden
setBusy(false);
}
return (
<div>
<div className="row" style={{ alignItems: 'stretch', gap: 8, flexWrap: compact ? 'nowrap' : 'wrap' }}>
<button type="button" onClick={reveal} disabled={busy && !secret} aria-label={secret ? 'Lizenzschlüssel (sichtbar)' : 'Lizenzschlüssel anzeigen'} className="mono"
style={{ flex: '1 1 auto', minWidth: compact ? 260 : 280, textAlign: 'left', padding: compact ? '6px 10px' : '10px 14px', minHeight: compact ? 36 : 44, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--surface-2)', color: 'var(--text)',
cursor: secret ? 'text' : 'pointer', fontSize: compact ? '.85rem' : '1.05rem', userSelect: secret ? 'all' : 'none', filter: secret ? 'none' : 'blur(6px)', transition: 'filter .15s' }}>
{secret ?? 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
</button>
<button type="button" className={`btn${compact ? ' small' : ''}`} onClick={copy} disabled={busy}>{busy ? '…' : copied ? 'Kopiert ✓' : 'Kopieren'}</button>
{secret && <button type="button" className={`btn${compact ? ' small' : ''}`} onClick={() => { setSecret(null); setCopied(false); }}>Verbergen</button>}
</div>
<div className="small muted" style={{ marginTop: 6 }} role="status">{err ? <span style={{ color: 'var(--err)' }}>{err}</span> : secret ? `Wird in ${left} Sekunden wieder verborgen. Bitte vertraulich behandeln.` : compact ? 'Klicken zum Anzeigen' : 'Zum Anzeigen auf das Feld klicken. Jeder Abruf wird protokolliert.'}{copied && !secret ? ' In die Zwischenablage kopiert.' : ''}</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
/** Einheitliches Statussystem: Text + Symbol, nie nur Farbe. */
const MAP: Record<string, { cls: string; icon: string; label: string }> = {
active: { cls: 'ok', icon: '●', label: 'Aktiv' }, invited: { cls: 'info', icon: '◐', label: 'Eingeladen' },
disabled: { cls: 'lock', icon: '⊘', label: 'Gesperrt' }, suspended: { cls: 'lock', icon: '⊘', label: 'Gesperrt' },
closed: { cls: 'lock', icon: '■', label: 'Beendet' }, warning: { cls: 'warn', icon: '▲', label: 'Warnung' }, error: { cls: 'err', icon: '✕', label: 'Gestört' },
};
export function Status({ value }: { value: string }) {
const m = MAP[value] ?? { cls: 'info', icon: '○', label: value };
return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>;
}
export const Alert = ({ kind, children }: { kind: 'err' | 'ok' | 'warn' | 'info'; children: ReactNode }) => <div className={`alert ${kind}`} role={kind === 'err' ? 'alert' : 'status'}>{children}</div>;
export const Field = ({ id, label, children, hint }: { id: string; label: string; children: ReactNode; hint?: string }) => (
<div className="field"><label htmlFor={id}>{label}</label>{children}{hint && <span className="muted small">{hint}</span>}</div>
);
export const Empty = ({ title, children }: { title: string; children?: ReactNode }) => <div className="empty"><h3>{title}</h3>{children}</div>;
export const fmt = (d: string | Date) => new Date(d).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
export const ROLE_LABEL: Record<string, string> = { support: 'Support', accounting: 'Buchhaltung', admin: 'Administrator', superadmin: 'Superadministrator', owner: 'Inhaber', member: 'Mitglied' };
export const STATE: Record<string, { cls: string; icon: string; label: string }> = {
active: { cls: 'ok', icon: '●', label: 'Aktiv' }, suspended: { cls: 'lock', icon: '⊘', label: 'Gesperrt' }, expired: { cls: 'warn', icon: '▲', label: 'Abgelaufen' },
error: { cls: 'err', icon: '✕', label: 'Gestört' }, unknown: { cls: 'info', icon: '○', label: 'Unbekannt' },
};
export function ResState({ value }: { value: string }) { const m = STATE[value] ?? STATE.unknown!; return <span className={`badge ${m.cls}`}><span aria-hidden="true">{m.icon}</span>{m.label}</span>; }
export const JOB_LABEL: Record<string, string> = { scheduled: 'Geplant', running: 'Wird ausgeführt', waiting_external: 'Wartet auf externes System', succeeded: 'Erfolgreich', partial: 'Teilweise erfolgreich', failed: 'Fehlgeschlagen', retrying: 'Wird wiederholt', needs_review: 'Manuelle Prüfung erforderlich' };
export function JobStatus({ value }: { value: string }) {
const cls = value === 'succeeded' ? 'ok' : value === 'failed' || value === 'needs_review' ? 'err' : value === 'retrying' || value === 'partial' ? 'warn' : 'info';
const icon = cls === 'ok' ? '✓' : cls === 'err' ? '✕' : cls === 'warn' ? '▲' : '◐';
return <span className={`badge ${cls}`}><span aria-hidden="true">{icon}</span>{JOB_LABEL[value] ?? value}</span>;
}
export const ACTION_LABEL: Record<string, string> = { suspend: 'Sperren', unsuspend: 'Entsperren', extend: 'Verlängern', 'panel.login': 'Panel-Login', 'domain.manage': 'Domains verwalten', 'email.manage': 'Postfächer verwalten', 'database.manage': 'Datenbanken verwalten', 'ftp.manage': 'FTP-Zugänge verwalten' };
/** Aktionen, die einem Kunden je Produkt/Ressource freigegeben werden können. */
export const CUSTOMER_ACTIONS = ['suspend', 'unsuspend', 'extend', 'panel.login', 'domain.manage', 'email.manage', 'database.manage', 'ftp.manage'] as const;
export const eur = (cents: number): string => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
export const INTERVAL: Record<string, string> = { once: 'einmalig', monthly: 'monatlich', yearly: 'jährlich' };
const ORDER: Record<string, [string, string, string]> = {
pending_approval: ['warn', '◐', 'Wartet auf Freigabe'], approved: ['info', '◐', 'Freigegeben'], provisioning: ['info', '◐', 'Wird bereitgestellt'], completed: ['ok', '✓', 'Abgeschlossen'],
failed: ['err', '✕', 'Fehlgeschlagen'], rejected: ['lock', '⊘', 'Abgelehnt'], cancelled: ['lock', '⊘', 'Storniert'],
};
const CONTRACT: Record<string, [string, string, string]> = {
pending: ['info', '◐', 'In Vorbereitung'], active: ['ok', '●', 'Aktiv'], suspended: ['lock', '⊘', 'Gesperrt'], cancelled: ['lock', '■', 'Beendet'], expired: ['warn', '▲', 'Abgelaufen'], failed: ['err', '✕', 'Fehlgeschlagen'],
};
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
export interface Price { interval: string; quantity: number; taxBp: number; discountBp: number; setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number } }
/** Preisdarstellung: Privatkunden brutto, Geschäftskunden netto mit Brutto in Klammern. */
export function PriceText({ p, priv }: { p: Price; priv: boolean }) {
const f = (x: { net: number; gross: number }) => (priv ? eur(x.gross) + ' inkl. USt.' : `${eur(x.net)} netto (${eur(x.gross)} brutto)`);
return <>{p.setup.net > 0 && <div>Einrichtung: {f(p.setup)}</div>}{(p.recurring.net > 0 || p.interval !== 'once') && <div>{INTERVAL[p.interval]}: {f(p.recurring)}</div>}{p.discountBp > 0 && <div className="small muted">inkl. {p.discountBp / 100} % Rabatt</div>}</>;
}

13
apps/web/src/lib/api.ts Normal file
View file

@ -0,0 +1,13 @@
'use client';
export class ApiError extends Error { constructor(public status: number, public code: string, message: string, public details?: { path: string; message: string }[]) { super(message); } }
let csrf = '';
export const setCsrf = (t: string) => { csrf = t; };
/** Einheitlicher API-Zugriff über den same-origin Proxy /api (Cookie, CSRF-Header, normalisierte Fehler). */
export async function api<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api${path}`, { method, credentials: 'same-origin', headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(csrf ? { 'x-csrf-token': csrf } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'ERROR', data?.error?.message ?? 'Anfrage fehlgeschlagen', data?.error?.details);
return data as T;
}
export const errMsg = (e: unknown): string => e instanceof ApiError ? (e.details?.length ? `${e.message}: ${e.details.map((d) => d.message).join(', ')}` : e.message) : 'Der Server ist nicht erreichbar. Bitte später erneut versuchen.';

View file

@ -0,0 +1,21 @@
'use client';
/**
* Text in die Zwischenablage kopieren. Die moderne Clipboard-API steht nur in sicheren Kontexten (HTTPS, localhost) zur Verfügung;
* über unverschlüsseltes HTTP (z. B. im Heimnetz) greift der Rückweg über eine markierte Textfläche und execCommand.
* Liefert true bei Erfolg. Der Text wird nirgends gespeichert; die temporäre Fläche wird sofort entfernt.
*/
export async function copyText(text: string): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; }
} catch { /* weiter mit Rückweg */ }
try {
const ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', ''); ta.setAttribute('aria-hidden', 'true');
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';
document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch { return false; }
}

View file

@ -0,0 +1,5 @@
'use client';
const ALPHABET = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789-_.!?#%+=';
/** Zufallspasswort mit dem Zufallsgenerator des Browsers (ohne verwechselbare Zeichen). */
export function generatePassword(len = 24): string { const b = new Uint32Array(len); crypto.getRandomValues(b); return Array.from(b, (n) => ALPHABET[n % ALPHABET.length]).join(''); }
export const MIN_CHILD_PASSWORD = 12;

View file

@ -0,0 +1,24 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { api, setCsrf, ApiError } from './api';
export interface Me {
user: { id: string; email: string; name: string }; kind: 'customer' | 'staff'; staffRole: string | null; permissions: string[];
pendingMfa: boolean; mfaEnrolled: boolean; mfaEnrollRequired: boolean; csrf: string;
organizations: { id: string; name: string; customerNumber: string; role: string }[];
}
interface Ctx { me: Me | null; loading: boolean; reload: () => Promise<Me | null>; can: (p: string) => boolean }
const C = createContext<Ctx>({ me: null, loading: true, reload: async () => null, can: () => false });
export const useSession = () => useContext(C);
export function SessionProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
try { const m = await api<Me>('GET', '/auth/me'); setCsrf(m.csrf); setMe(m); return m; }
catch (e) { if (!(e instanceof ApiError) || e.status !== 401) console.error(e); setMe(null); return null; }
finally { setLoading(false); }
}, []);
useEffect(() => { void reload(); }, [reload]);
return <C.Provider value={{ me, loading, reload, can: (p) => !!me?.permissions.includes(p) }}>{children}</C.Provider>;
}

13
apps/web/src/lib/uuid.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Zufällige ID für Idempotenzschlüssel. `crypto.randomUUID` gibt es nur in sicheren Kontexten (HTTPS);
* über unverschlüsseltes HTTP (Heimnetz) greift der Rückweg über `getRandomValues`, der überall verfügbar ist.
*/
export function uuid(): string {
const c = typeof crypto !== 'undefined' ? crypto : undefined;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
const b = new Uint8Array(16);
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b); else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
b[6] = (b[6]! & 0x0f) | 0x40; b[8] = (b[8]! & 0x3f) | 0x80; // Version 4
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}

41
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more