Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
21
.env.example
Normal file
21
.env.example
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
50
README.md
Normal file
50
README.md
Normal 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
37
apps/api/package.json
Normal 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
56
apps/api/src/cli/index.ts
Normal 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();
|
||||||
23
apps/api/src/cli/migrate.ts
Normal file
23
apps/api/src/cli/migrate.ts
Normal 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(); }
|
||||||
27
apps/api/src/core/accounts.ts
Normal file
27
apps/api/src/core/accounts.ts
Normal 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`);
|
||||||
|
}
|
||||||
4
apps/api/src/core/actions.ts
Normal file
4
apps/api/src/core/actions.ts
Normal 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' };
|
||||||
1
apps/api/src/core/audit.ts
Normal file
1
apps/api/src/core/audit.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '@kc/platform/audit';
|
||||||
92
apps/api/src/core/auth.ts
Normal file
92
apps/api/src/core/auth.ts
Normal 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;
|
||||||
5
apps/api/src/core/config.ts
Normal file
5
apps/api/src/core/config.ts
Normal 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 } });
|
||||||
1
apps/api/src/core/crypto.ts
Normal file
1
apps/api/src/core/crypto.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '@kc/platform/crypto';
|
||||||
1
apps/api/src/core/db.ts
Normal file
1
apps/api/src/core/db.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '@kc/platform/db';
|
||||||
8
apps/api/src/core/errors.ts
Normal file
8
apps/api/src/core/errors.ts
Normal 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);
|
||||||
1
apps/api/src/core/jobs.ts
Normal file
1
apps/api/src/core/jobs.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from '@kc/platform/jobs';
|
||||||
23
apps/api/src/core/mail.ts
Normal file
23
apps/api/src/core/mail.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/core/module.ts
Normal file
10
apps/api/src/core/module.ts
Normal 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>;
|
||||||
|
}
|
||||||
42
apps/api/src/core/policy.ts
Normal file
42
apps/api/src/core/policy.ts
Normal 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);
|
||||||
|
}
|
||||||
27
apps/api/src/modules/audit/index.ts
Normal file
27
apps/api/src/modules/audit/index.ts
Normal 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();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
176
apps/api/src/modules/backup/index.ts
Normal file
176
apps/api/src/modules/backup/index.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
273
apps/api/src/modules/catalog/index.ts
Normal file
273
apps/api/src/modules/catalog/index.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
98
apps/api/src/modules/connectors/index.ts
Normal file
98
apps/api/src/modules/connectors/index.ts
Normal 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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
264
apps/api/src/modules/customers/index.ts
Normal file
264
apps/api/src/modules/customers/index.ts
Normal 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 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
132
apps/api/src/modules/domains/index.ts
Normal file
132
apps/api/src/modules/domains/index.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
93
apps/api/src/modules/domains/logic.ts
Normal file
93
apps/api/src/modules/domains/logic.ts
Normal 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);
|
||||||
|
}
|
||||||
277
apps/api/src/modules/identity/index.ts
Normal file
277
apps/api/src/modules/identity/index.ts
Normal 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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
14
apps/api/src/modules/index.ts
Normal file
14
apps/api/src/modules/index.ts
Normal 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];
|
||||||
213
apps/api/src/modules/orders/index.ts
Normal file
213
apps/api/src/modules/orders/index.ts
Normal 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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
258
apps/api/src/modules/resources/index.ts
Normal file
258
apps/api/src/modules/resources/index.ts
Normal 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' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
35
apps/api/src/modules/system/index.ts
Normal file
35
apps/api/src/modules/system/index.ts
Normal 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
209
apps/api/src/ops/backup.ts
Normal 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/api/src/ops/retention.ts
Normal file
26
apps/api/src/ops/retention.ts
Normal 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);
|
||||||
|
}
|
||||||
28
apps/api/src/ops/settings.ts
Normal file
28
apps/api/src/ops/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
94
apps/api/src/ops/targets.ts
Normal file
94
apps/api/src/ops/targets.ts
Normal 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
43
apps/api/src/server.ts
Normal 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' });
|
||||||
|
}
|
||||||
52
apps/api/test/backup-api.test.ts
Normal file
52
apps/api/test/backup-api.test.ts
Normal 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 []; } }
|
||||||
124
apps/api/test/backup-settings.test.ts
Normal file
124
apps/api/test/backup-settings.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
apps/api/test/backup.test.ts
Normal file
87
apps/api/test/backup.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
120
apps/api/test/connectors.test.ts
Normal file
120
apps/api/test/connectors.test.ts
Normal 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
218
apps/api/test/core.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
98
apps/api/test/domains.test.ts
Normal file
98
apps/api/test/domains.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/api/test/global-setup.ts
Normal file
21
apps/api/test/global-setup.ts
Normal 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
23
apps/api/test/helpers.ts
Normal 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 });
|
||||||
122
apps/api/test/keyhelp.test.ts
Normal file
122
apps/api/test/keyhelp.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
268
apps/api/test/orders.test.ts
Normal file
268
apps/api/test/orders.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/api/test/reveal.test.ts
Normal file
65
apps/api/test/reveal.test.ts
Normal 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
17
apps/api/test/setup.ts
Normal 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
1
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"] }
|
||||||
2
apps/api/vitest.config.ts
Normal file
2
apps/api/vitest.config.ts
Normal 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
7
apps/web/next-env.d.ts
vendored
Normal 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
14
apps/web/next.config.mjs
Normal 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
25
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/web/src/app/(app)/admin/audit/page.tsx
Normal file
21
apps/web/src/app/(app)/admin/audit/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
23
apps/web/src/app/(app)/admin/auftraege/page.tsx
Normal file
23
apps/web/src/app/(app)/admin/auftraege/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
45
apps/web/src/app/(app)/admin/benutzer/page.tsx
Normal file
45
apps/web/src/app/(app)/admin/benutzer/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
11
apps/web/src/app/(app)/admin/domains/page.tsx
Normal file
11
apps/web/src/app/(app)/admin/domains/page.tsx
Normal 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 & 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 />
|
||||||
|
</>);
|
||||||
|
}
|
||||||
81
apps/web/src/app/(app)/admin/kunden/[id]/page.tsx
Normal file
81
apps/web/src/app/(app)/admin/kunden/[id]/page.tsx
Normal 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 & 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
53
apps/web/src/app/(app)/admin/kunden/page.tsx
Normal file
53
apps/web/src/app/(app)/admin/kunden/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
70
apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx
Normal file
70
apps/web/src/app/(app)/admin/kunden/uebernahme/page.tsx
Normal 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>)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
50
apps/web/src/app/(app)/admin/produkte/[id]/page.tsx
Normal file
50
apps/web/src/app/(app)/admin/produkte/[id]/page.tsx
Normal 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>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
63
apps/web/src/app/(app)/admin/produkte/domains/page.tsx
Normal file
63
apps/web/src/app/(app)/admin/produkte/domains/page.tsx
Normal 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>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
63
apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx
Normal file
63
apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx
Normal 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>)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
61
apps/web/src/app/(app)/admin/produkte/page.tsx
Normal file
61
apps/web/src/app/(app)/admin/produkte/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
149
apps/web/src/app/(app)/admin/produkte/uebernahme/page.tsx
Normal file
149
apps/web/src/app/(app)/admin/produkte/uebernahme/page.tsx
Normal 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>))}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
36
apps/web/src/app/(app)/bestellen/page.tsx
Normal file
36
apps/web/src/app/(app)/bestellen/page.tsx
Normal 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>}
|
||||||
|
</>)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
33
apps/web/src/app/(app)/bestellungen/page.tsx
Normal file
33
apps/web/src/app/(app)/bestellungen/page.tsx
Normal 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>))}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
50
apps/web/src/app/(app)/dashboard/page.tsx
Normal file
50
apps/web/src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||||
|
</>)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
29
apps/web/src/app/(app)/domains/page.tsx
Normal file
29
apps/web/src/app/(app)/domains/page.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
79
apps/web/src/app/(app)/einstellungen/backup/page.tsx
Normal file
79
apps/web/src/app/(app)/einstellungen/backup/page.tsx
Normal 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>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
23
apps/web/src/app/(app)/einstellungen/layout.tsx
Normal file
23
apps/web/src/app/(app)/einstellungen/layout.tsx
Normal 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}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
9
apps/web/src/app/(app)/einstellungen/page.tsx
Normal file
9
apps/web/src/app/(app)/einstellungen/page.tsx
Normal 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>;
|
||||||
|
}
|
||||||
50
apps/web/src/app/(app)/einstellungen/verbindungen/page.tsx
Normal file
50
apps/web/src/app/(app)/einstellungen/verbindungen/page.tsx
Normal 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>); })}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
77
apps/web/src/app/(app)/konto/page.tsx
Normal file
77
apps/web/src/app/(app)/konto/page.tsx
Normal 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></>)}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
47
apps/web/src/app/(app)/layout.tsx
Normal file
47
apps/web/src/app/(app)/layout.tsx
Normal 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>);
|
||||||
|
}
|
||||||
35
apps/web/src/app/(app)/organisation/page.tsx
Normal file
35
apps/web/src/app/(app)/organisation/page.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
79
apps/web/src/app/(app)/ressourcen/[id]/page.tsx
Normal file
79
apps/web/src/app/(app)/ressourcen/[id]/page.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
63
apps/web/src/app/(app)/ressourcen/page.tsx
Normal file
63
apps/web/src/app/(app)/ressourcen/page.tsx
Normal 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>); })}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
41
apps/web/src/app/(app)/vertraege/[id]/page.tsx
Normal file
41
apps/web/src/app/(app)/vertraege/[id]/page.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
21
apps/web/src/app/(app)/vertraege/page.tsx
Normal file
21
apps/web/src/app/(app)/vertraege/page.tsx
Normal 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></>);
|
||||||
|
}
|
||||||
25
apps/web/src/app/einladung/page.tsx
Normal file
25
apps/web/src/app/einladung/page.tsx
Normal 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>; }
|
||||||
56
apps/web/src/app/globals.css
Normal file
56
apps/web/src/app/globals.css
Normal 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; } }
|
||||||
9
apps/web/src/app/layout.tsx
Normal file
9
apps/web/src/app/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
45
apps/web/src/app/login/page.tsx
Normal file
45
apps/web/src/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/app/page.tsx
Normal file
9
apps/web/src/app/page.tsx
Normal 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>;
|
||||||
|
}
|
||||||
25
apps/web/src/app/passwort-reset/page.tsx
Normal file
25
apps/web/src/app/passwort-reset/page.tsx
Normal 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>; }
|
||||||
14
apps/web/src/app/passwort-vergessen/page.tsx
Normal file
14
apps/web/src/app/passwort-vergessen/page.tsx
Normal 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>;
|
||||||
|
}
|
||||||
50
apps/web/src/components/BackupPassword.tsx
Normal file
50
apps/web/src/components/BackupPassword.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/web/src/components/BackupTargets.tsx
Normal file
65
apps/web/src/components/BackupTargets.tsx
Normal 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 "drive" "eyJzY29wZSI6ImRyaXZlLmZpbGUifQ"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/src/components/ConfigFields.tsx
Normal file
40
apps/web/src/components/ConfigFields.tsx
Normal 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>}
|
||||||
|
</>);
|
||||||
|
}
|
||||||
51
apps/web/src/components/DomainRecords.tsx
Normal file
51
apps/web/src/components/DomainRecords.tsx
Normal 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>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
116
apps/web/src/components/HostingPanel.tsx
Normal file
116
apps/web/src/components/HostingPanel.tsx
Normal 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>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
43
apps/web/src/components/SecretField.tsx
Normal file
43
apps/web/src/components/SecretField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/web/src/components/ui.tsx
Normal file
53
apps/web/src/components/ui.tsx
Normal 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
13
apps/web/src/lib/api.ts
Normal 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.';
|
||||||
21
apps/web/src/lib/clipboard.ts
Normal file
21
apps/web/src/lib/clipboard.ts
Normal 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; }
|
||||||
|
}
|
||||||
5
apps/web/src/lib/password.ts
Normal file
5
apps/web/src/lib/password.ts
Normal 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;
|
||||||
24
apps/web/src/lib/session.tsx
Normal file
24
apps/web/src/lib/session.tsx
Normal 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
13
apps/web/src/lib/uuid.ts
Normal 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
41
apps/web/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
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
Loading…
Add table
Add a link
Reference in a new issue