From 2c7a87ccd3a1ca8d4198f7e0a4e0e763196d9e63 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 7 May 2026 19:26:33 +0200 Subject: [PATCH] =?UTF-8?q?factory-defaults:=20HTML-Templates=20+=20Import?= =?UTF-8?q?=20=C3=BCber=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erweitert das bestehende Factory-Defaults-Bundle um vier HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage, Website-Datenschutz) und ergänzt den bisherigen CLI-Only-Import um einen Upload-Pfad in der UI. Backend: - collectFactoryDefaults() zieht jetzt auch die Whitelist-AppSettings - exportFactoryDefaults() legt sie als app-settings/app-settings.json ins ZIP - importFactoryDefaults(buffer) liest die ZIP idempotent ein – upserts pro Kategorie, Whitelist-Filter für AppSettings, Anti-Zip-Slip durch basename beim PDF-Lookup - POST /api/factory-defaults/import (multer memoryStorage, max 50 MB, settings:update) - seed-factory-defaults.ts (CLI) gleichermaßen um seedAppSettings() erweitert Frontend: - Import-Card in FactoryDefaults.tsx: Datei-Upload statt CLI-Anleitung - Erfolgs-Box mit Counts pro Kategorie + Warnings (z.B. fehlende PDFs im ZIP) - Preview zeigt jetzt auch die Anzahl HTML-Templates Live verifiziert: Round-Trip Export → DELETE privacyPolicyHtml → Import → Wert (13.6 KB) wieder vollständig hergestellt, Audit-Log zeigt EXPORT + UPDATE-Eintrag mit Detail-Counts. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/factory-defaults/README.md | 46 ++- backend/scripts/seed-factory-defaults.ts | 39 +++ .../controllers/factoryDefaults.controller.ts | 37 +++ backend/src/routes/factoryDefaults.routes.ts | 25 ++ .../src/services/factoryDefaults.service.ts | 275 +++++++++++++++++- docs/todo.md | 10 + .../src/pages/settings/FactoryDefaults.tsx | 190 +++++++++--- 7 files changed, 568 insertions(+), 54 deletions(-) diff --git a/backend/factory-defaults/README.md b/backend/factory-defaults/README.md index d2e52a60..6023cedb 100644 --- a/backend/factory-defaults/README.md +++ b/backend/factory-defaults/README.md @@ -18,15 +18,21 @@ backend/factory-defaults/ │ ├── cancellation-periods.json # Kündigungsfristen │ ├── contract-durations.json # Vertragslaufzeiten │ └── contract-categories.json # Vertragskategorien (Strom/Gas/DSL/...) -└── pdf-templates/ - ├── pdf-templates.json # Metadaten + Feldzuordnungen - └── *.pdf # PDF-Vorlagen-Dateien +├── pdf-templates/ +│ ├── pdf-templates.json # Metadaten + Feldzuordnungen +│ └── *.pdf # PDF-Vorlagen-Dateien +└── app-settings/ + └── app-settings.json # HTML-Templates: Datenschutz / Impressum / + # Vollmacht / Website-Datenschutz ``` **Was NICHT enthalten ist:** Kundendaten, Verträge, Dokumente, E-Mails, SMTP-Einstellungen, -Datenschutzerklärungen oder andere AppSettings. Dafür gibt es den separaten +Secrets oder benutzerspezifische AppSettings. Dafür gibt es den separaten **Datenbank-Backup-Export** (Einstellungen → Datenbank & Zurücksetzen). +Bei den AppSettings ist nur eine **Whitelist** vorgesehen (HTML-Texte für rechtliche +Standardpflichten) – andere Keys werden beim Import ignoriert. + --- ## Export (aus einer bestehenden Installation) @@ -46,7 +52,8 @@ factory-defaults-2026-04-23.zip ├── contract-meta/contract-durations.json ├── contract-meta/contract-categories.json ├── pdf-templates/pdf-templates.json -└── pdf-templates/*.pdf +├── pdf-templates/*.pdf +└── app-settings/app-settings.json ``` Die ZIP kann an andere Installationen weitergegeben werden – z.B. für Test-Systeme, @@ -56,7 +63,15 @@ neue Installationen oder Partner-Setups. ## Import (in eine andere Installation) -### Schritt-für-Schritt +### Variante A: Über die UI (empfohlen) + +1. Im Ziel-CRM als Admin einloggen +2. **Einstellungen → Factory-Defaults** +3. Im Bereich **Import** auf **„ZIP hochladen"** klicken +4. Die exportierte ZIP wählen – der Import läuft direkt +5. Erfolgsmeldung zeigt Counts pro Kategorie an + +### Variante B: Über die CLI (für Bare-Metal / Migration / mehrere ZIPs zusammenführen) 1. **ZIP herunterladen** (aus einer Export-Installation oder von einer Vorlage) 2. **Inhalt entpacken** in diesen Ordner (`backend/factory-defaults/`), @@ -234,6 +249,24 @@ Array von Providern, jeweils inkl. zugehöriger Tarife: **Unique Key:** `name` **Wichtig:** Die `pdfFilename` muss zu einer PDF-Datei im selben Ordner passen. +### `app-settings/app-settings.json` + +HTML-Standardtexte als Werkseinstellung. Es ist eine **Whitelist** aktiv – andere Keys +werden beim Import ignoriert (Schutz vor versehentlichem Überschreiben von Secrets). + +```json +[ + { "key": "privacyPolicyHtml", "value": "

Datenschutzerklärung

..." }, + { "key": "imprintHtml", "value": "

Impressum

..." }, + { "key": "authorizationTemplateHtml","value": "

Vollmacht

..." }, + { "key": "websitePrivacyPolicyHtml", "value": "

Website-Datenschutz

..." } +] +``` + +**Unique Key:** `key` +**Erlaubte Keys:** `privacyPolicyHtml`, `imprintHtml`, `authorizationTemplateHtml`, +`websitePrivacyPolicyHtml`. + --- ## Berechtigungen @@ -242,6 +275,7 @@ Array von Providern, jeweils inkl. zugehöriger Tarife: |--------|--------------| | Factory-Defaults Vorschau | `settings:read` | | Factory-Defaults Export (UI) | `settings:update` | +| Factory-Defaults Import (UI) | `settings:update` | | Factory-Defaults Import (CLI) | Server-Zugang (SSH/Shell) | --- diff --git a/backend/scripts/seed-factory-defaults.ts b/backend/scripts/seed-factory-defaults.ts index a1a0d728..50e1b43f 100644 --- a/backend/scripts/seed-factory-defaults.ts +++ b/backend/scripts/seed-factory-defaults.ts @@ -61,6 +61,19 @@ interface PdfTemplateDef { pdfFilename: string; // Dateiname im pdf-templates/-Ordner } +interface AppSettingDef { + key: string; + value: string; +} + +// Whitelist – muss synchron zu factoryDefaults.service.ts sein. +const FACTORY_DEFAULT_APP_SETTING_KEYS = new Set([ + 'privacyPolicyHtml', + 'authorizationTemplateHtml', + 'imprintHtml', + 'websitePrivacyPolicyHtml', +]); + /** * Liest alle *.json Dateien aus einem Ordner und gibt die zusammengeführten Arrays zurück. */ @@ -299,6 +312,31 @@ async function seedPdfTemplates() { console.log(` ✓ PDF-Vorlagen: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`); } +async function seedAppSettings() { + const items = readJsonArrays(path.join(ROOT, 'app-settings')); + if (items.length === 0) { + console.log(' app-settings – keine Einträge'); + return; + } + let count = 0; + let skipped = 0; + for (const s of items) { + if (!s.key || typeof s.value !== 'string') continue; + if (!FACTORY_DEFAULT_APP_SETTING_KEYS.has(s.key)) { + console.warn(` ⚠ AppSetting-Key '${s.key}' nicht auf Whitelist – übersprungen`); + skipped++; + continue; + } + await prisma.appSetting.upsert({ + where: { key: s.key }, + update: { value: s.value }, + create: { key: s.key, value: s.value }, + }); + count++; + } + console.log(` ✓ HTML-Templates: ${count}${skipped > 0 ? ` (${skipped} übersprungen)` : ''}`); +} + async function main() { console.log('\n📦 Factory-Defaults werden eingespielt...\n'); @@ -313,6 +351,7 @@ async function main() { await seedContractDurations(); await seedContractCategories(); await seedPdfTemplates(); + await seedAppSettings(); console.log('\n✅ Factory-Defaults erfolgreich eingespielt.\n'); } diff --git a/backend/src/controllers/factoryDefaults.controller.ts b/backend/src/controllers/factoryDefaults.controller.ts index fab771a7..1e483b9e 100644 --- a/backend/src/controllers/factoryDefaults.controller.ts +++ b/backend/src/controllers/factoryDefaults.controller.ts @@ -54,6 +54,7 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) { contractDurations: data.contractDurations.length, contractCategories: data.contractCategories.length, pdfTemplates: data.pdfTemplates.length, + appSettings: data.appSettings.length, }, }, }); @@ -62,3 +63,39 @@ export async function previewFactoryDefaults(req: AuthRequest, res: Response) { res.status(500).json({ success: false, error: 'Fehler beim Laden' }); } } + +/** + * Factory-Defaults aus ZIP importieren (Upload via multipart/form-data, Feld 'zip'). + * Idempotent: bestehende Einträge werden per unique-Key aktualisiert, nichts wird gelöscht. + */ +export async function importFactoryDefaults(req: AuthRequest, res: Response) { + try { + const file = (req as any).file as Express.Multer.File | undefined; + if (!file || !file.buffer) { + return res.status(400).json({ success: false, error: 'Keine ZIP-Datei hochgeladen' }); + } + + const result = await factoryDefaultsService.importFactoryDefaults(file.buffer); + + await createAuditLog({ + userId: req.user?.userId, + userEmail: req.user?.email || 'unknown', + // 'UPDATE' weil Factory-Defaults DB-Records upserted; das Label nennt + // den Vorgang explizit als Import. + action: 'UPDATE', + resourceType: 'FactoryDefaults', + resourceLabel: `Factory-Defaults importiert: ${result.providers} Anbieter, ${result.tariffs} Tarife, ${result.pdfTemplates} PDF-Vorlagen, ${result.appSettings} HTML-Templates`, + endpoint: req.path, + httpMethod: req.method, + ipAddress: req.socket.remoteAddress || 'unknown', + }); + + res.json({ success: true, data: result }); + } catch (error) { + console.error('Fehler beim Factory-Defaults-Import:', error); + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Import', + }); + } +} diff --git a/backend/src/routes/factoryDefaults.routes.ts b/backend/src/routes/factoryDefaults.routes.ts index c54ba3fa..56b26905 100644 --- a/backend/src/routes/factoryDefaults.routes.ts +++ b/backend/src/routes/factoryDefaults.routes.ts @@ -1,9 +1,25 @@ import { Router } from 'express'; +import multer from 'multer'; import * as factoryDefaultsController from '../controllers/factoryDefaults.controller.js'; import { authenticate, requirePermission } from '../middleware/auth.js'; const router = Router(); +// In-Memory-Upload für die ZIP – wird direkt verarbeitet, keine temporäre Datei. +const upload = multer({ + storage: multer.memoryStorage(), + fileFilter: (_req, file, cb) => { + const ok = + file.mimetype === 'application/zip' || + file.mimetype === 'application/x-zip-compressed' || + file.mimetype === 'application/octet-stream' || // manche Browser senden das für .zip + file.originalname.toLowerCase().endsWith('.zip'); + if (ok) cb(null, true); + else cb(new Error('Nur ZIP-Dateien sind erlaubt')); + }, + limits: { fileSize: 50 * 1024 * 1024 }, +}); + // Preview (was wäre im Export drin?) router.get( '/preview', @@ -20,4 +36,13 @@ router.get( factoryDefaultsController.exportFactoryDefaults, ); +// Import aus ZIP (multipart, Feld 'zip') +router.post( + '/import', + authenticate, + requirePermission('settings:update'), + upload.single('zip'), + factoryDefaultsController.importFactoryDefaults, +); + export default router; diff --git a/backend/src/services/factoryDefaults.service.ts b/backend/src/services/factoryDefaults.service.ts index 964303e6..ba8a8eb9 100644 --- a/backend/src/services/factoryDefaults.service.ts +++ b/backend/src/services/factoryDefaults.service.ts @@ -1,15 +1,32 @@ /** * Factory-Defaults: Export + Import von Stammdaten-Katalogen. - * Enthält KEINE Kundendaten, Verträge, Dokumente oder Einstellungen – + * Enthält KEINE Kundendaten, Verträge, Dokumente oder E-Mails – * nur reine Kataloge: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, - * Vertragskategorien und PDF-Auftragsvorlagen. + * Vertragskategorien, PDF-Auftragsvorlagen und ausgewählte + * HTML-Templates (Datenschutz / Impressum / Vollmacht). */ import fs from 'fs'; import path from 'path'; import archiver from 'archiver'; +import AdmZip from 'adm-zip'; import prisma from '../lib/prisma.js'; +// Whitelist der AppSetting-Keys, die ins Factory-Default-Bundle gehören. +// Bewusst klein gehalten: nur HTML-Templates für rechtliche Standardtexte – +// keine Secrets, keine SMTP-Konfiguration, keine User-spezifischen Settings. +export const FACTORY_DEFAULT_APP_SETTING_KEYS = [ + 'privacyPolicyHtml', + 'authorizationTemplateHtml', + 'imprintHtml', + 'websitePrivacyPolicyHtml', +] as const; + +export interface AppSettingExport { + key: (typeof FACTORY_DEFAULT_APP_SETTING_KEYS)[number]; + value: string; +} + export interface FactoryDefaultsManifest { version: 1; exportedAt: string; @@ -20,6 +37,7 @@ export interface FactoryDefaultsManifest { contractDurations: number; contractCategories: number; pdfTemplates: number; + appSettings: number; }; } @@ -49,7 +67,7 @@ export interface PdfTemplateExport { * Sammelt alle Katalog-Daten aus der DB. */ export async function collectFactoryDefaults() { - const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates] = + const [providers, cancellationPeriods, contractDurations, contractCategories, pdfTemplates, appSettings] = await Promise.all([ prisma.provider.findMany({ include: { tariffs: { select: { name: true, isActive: true } } }, @@ -59,6 +77,11 @@ export async function collectFactoryDefaults() { prisma.contractDuration.findMany({ orderBy: { code: 'asc' } }), prisma.contractCategory.findMany({ orderBy: { sortOrder: 'asc' } }), prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' } }), + prisma.appSetting.findMany({ + where: { key: { in: [...FACTORY_DEFAULT_APP_SETTING_KEYS] } }, + select: { key: true, value: true }, + orderBy: { key: 'asc' }, + }), ]); return { @@ -108,6 +131,7 @@ export async function collectFactoryDefaults() { pdfFilename: sanitizeFilename(t.name) + path.extname(t.originalName || '.pdf'), }; }), + appSettings: appSettings as AppSettingExport[], }; } @@ -132,6 +156,7 @@ export async function exportFactoryDefaults(): Promise { contractDurations: data.contractDurations.length, contractCategories: data.contractCategories.length, pdfTemplates: data.pdfTemplates.length, + appSettings: data.appSettings.length, }, }; @@ -160,6 +185,9 @@ export async function exportFactoryDefaults(): Promise { archive.append(JSON.stringify(data.pdfTemplates, null, 2), { name: 'pdf-templates/pdf-templates.json', }); + archive.append(JSON.stringify(data.appSettings, null, 2), { + name: 'app-settings/app-settings.json', + }); // PDF-Dateien physisch hinzufügen (Pfade aus DB laden) const uploadsRoot = path.join(process.cwd(), 'uploads'); @@ -192,3 +220,244 @@ export async function exportFactoryDefaults(): Promise { })(); }); } + +// ============================================================ +// IMPORT +// ============================================================ + +export interface FactoryDefaultsImportResult { + providers: number; + tariffs: number; + cancellationPeriods: number; + contractDurations: number; + contractCategories: number; + pdfTemplates: number; + pdfTemplatesSkipped: number; + appSettings: number; + warnings: string[]; +} + +function parseJsonEntry(zip: AdmZip, name: string): T[] { + const entry = zip.getEntry(name); + if (!entry) return []; + try { + const parsed = JSON.parse(entry.getData().toString('utf-8')); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Wendet ein Factory-Defaults-ZIP idempotent auf die DB an. + * - upsert über unique-Keys: nichts wird gelöscht + * - PDFs landen in `${uploads}/pdf-templates/` mit eindeutigem Suffix + * - AppSettings nur Whitelist-Keys (FACTORY_DEFAULT_APP_SETTING_KEYS) + * + * Robust gegen Zip-Slip: wir greifen nur auf bekannte Entry-Namen zu + * (`pdf-templates/`), niemals auf einen aus dem ZIP konstruierten + * Pfad im Filesystem. + */ +export async function importFactoryDefaults( + zipBuffer: Buffer, +): Promise { + const zip = new AdmZip(zipBuffer); + const result: FactoryDefaultsImportResult = { + providers: 0, + tariffs: 0, + cancellationPeriods: 0, + contractDurations: 0, + contractCategories: 0, + pdfTemplates: 0, + pdfTemplatesSkipped: 0, + appSettings: 0, + warnings: [], + }; + + // --- Providers + Tariffs + const providers = parseJsonEntry(zip, 'providers/providers.json'); + for (const p of providers) { + if (!p.name) continue; + const provider = await prisma.provider.upsert({ + where: { name: p.name }, + update: { + portalUrl: p.portalUrl ?? null, + usernameFieldName: p.usernameFieldName ?? null, + passwordFieldName: p.passwordFieldName ?? null, + isActive: p.isActive ?? true, + }, + create: { + name: p.name, + portalUrl: p.portalUrl ?? null, + usernameFieldName: p.usernameFieldName ?? null, + passwordFieldName: p.passwordFieldName ?? null, + isActive: p.isActive ?? true, + }, + }); + result.providers++; + for (const t of p.tariffs ?? []) { + if (!t.name) continue; + await prisma.tariff.upsert({ + where: { providerId_name: { providerId: provider.id, name: t.name } }, + update: { isActive: t.isActive ?? true }, + create: { providerId: provider.id, name: t.name, isActive: t.isActive ?? true }, + }); + result.tariffs++; + } + } + + // --- Contract-Meta + const cancellationPeriods = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>( + zip, + 'contract-meta/cancellation-periods.json', + ); + for (const c of cancellationPeriods) { + if (!c.code || !c.description) continue; + await prisma.cancellationPeriod.upsert({ + where: { code: c.code }, + update: { description: c.description, isActive: c.isActive ?? true }, + create: { code: c.code, description: c.description, isActive: c.isActive ?? true }, + }); + result.cancellationPeriods++; + } + + const contractDurations = parseJsonEntry<{ code: string; description: string; isActive?: boolean }>( + zip, + 'contract-meta/contract-durations.json', + ); + for (const d of contractDurations) { + if (!d.code || !d.description) continue; + await prisma.contractDuration.upsert({ + where: { code: d.code }, + update: { description: d.description, isActive: d.isActive ?? true }, + create: { code: d.code, description: d.description, isActive: d.isActive ?? true }, + }); + result.contractDurations++; + } + + const contractCategories = parseJsonEntry<{ + code: string; + name: string; + icon?: string | null; + color?: string | null; + sortOrder?: number; + isActive?: boolean; + }>(zip, 'contract-meta/contract-categories.json'); + for (const c of contractCategories) { + if (!c.code || !c.name) continue; + await prisma.contractCategory.upsert({ + where: { code: c.code }, + update: { + name: c.name, + icon: c.icon ?? null, + color: c.color ?? null, + sortOrder: c.sortOrder ?? 0, + isActive: c.isActive ?? true, + }, + create: { + code: c.code, + name: c.name, + icon: c.icon ?? null, + color: c.color ?? null, + sortOrder: c.sortOrder ?? 0, + isActive: c.isActive ?? true, + }, + }); + result.contractCategories++; + } + + // --- PDF-Vorlagen (JSON + binär aus dem ZIP) + const pdfTemplates = parseJsonEntry( + zip, + 'pdf-templates/pdf-templates.json', + ); + if (pdfTemplates.length > 0) { + const uploadsRoot = path.join(process.cwd(), 'uploads'); + const pdfDestDir = path.join(uploadsRoot, 'pdf-templates'); + if (!fs.existsSync(pdfDestDir)) { + fs.mkdirSync(pdfDestDir, { recursive: true }); + } + for (const t of pdfTemplates) { + if (!t.name || !t.pdfFilename) continue; + // Anti-Zip-Slip: nur basename verwenden, kein Pfad + const basename = path.basename(t.pdfFilename); + const entry = zip.getEntry(`pdf-templates/${basename}`); + if (!entry) { + result.pdfTemplatesSkipped++; + result.warnings.push(`PDF fehlt im ZIP: ${basename} – Vorlage "${t.name}" übersprungen`); + continue; + } + + const ext = path.extname(t.originalName || basename) || '.pdf'; + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const safeName = t.name.replace(/[^a-zA-Z0-9]/g, '-'); + const destFilename = `seed-${safeName}-${uniqueSuffix}${ext}`; + const destPdf = path.join(pdfDestDir, destFilename); + const relativePath = `/uploads/pdf-templates/${destFilename}`; + + fs.writeFileSync(destPdf, entry.getData()); + + // Bei existierender Vorlage die alte Datei aufräumen + const existing = await prisma.pdfTemplate.findUnique({ where: { name: t.name } }); + if (existing?.templatePath) { + const oldRel = existing.templatePath.startsWith('/uploads/') + ? existing.templatePath.substring('/uploads/'.length) + : existing.templatePath; + const oldAbs = path.join(uploadsRoot, oldRel); + if (fs.existsSync(oldAbs)) { + try { + fs.unlinkSync(oldAbs); + } catch { + // ignore + } + } + } + + const fieldMappingJson = JSON.stringify(t.fieldMapping ?? {}); + await prisma.pdfTemplate.upsert({ + where: { name: t.name }, + update: { + description: t.description ?? null, + providerName: t.providerName ?? null, + templatePath: relativePath, + originalName: t.originalName, + fieldMapping: fieldMappingJson, + phoneFieldPrefix: t.phoneFieldPrefix ?? null, + maxPhoneFields: t.maxPhoneFields ?? 8, + isActive: t.isActive ?? true, + }, + create: { + name: t.name, + description: t.description ?? null, + providerName: t.providerName ?? null, + templatePath: relativePath, + originalName: t.originalName, + fieldMapping: fieldMappingJson, + phoneFieldPrefix: t.phoneFieldPrefix ?? null, + maxPhoneFields: t.maxPhoneFields ?? 8, + isActive: t.isActive ?? true, + }, + }); + result.pdfTemplates++; + } + } + + // --- AppSettings (HTML-Templates, Whitelist) + const appSettings = parseJsonEntry(zip, 'app-settings/app-settings.json'); + const allowedKeys = new Set(FACTORY_DEFAULT_APP_SETTING_KEYS); + for (const s of appSettings) { + if (!s.key || typeof s.value !== 'string') continue; + if (!allowedKeys.has(s.key)) { + result.warnings.push(`AppSetting-Key '${s.key}' nicht auf Whitelist – ignoriert`); + continue; + } + await prisma.appSetting.upsert({ + where: { key: s.key }, + update: { value: s.value }, + create: { key: s.key, value: s.value }, + }); + result.appSettings++; + } + + return result; +} diff --git a/docs/todo.md b/docs/todo.md index 59bacd3a..574d8ec9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,16 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **📦 Factory-Defaults: HTML-Templates + Import via UI** + - Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz + werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner, + Whitelist-geschützt – andere AppSetting-Keys werden ignoriert). + - Import läuft jetzt auch über die UI (Einstellungen → Factory-Defaults → + „ZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und + wurde gleichermaßen um die HTML-Templates erweitert. + - Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen → + Import → Wert wieder vollständig hergestellt; Counts in Audit-Log. + - [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar** - Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` / `hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen diff --git a/frontend/src/pages/settings/FactoryDefaults.tsx b/frontend/src/pages/settings/FactoryDefaults.tsx index 79c77567..8193659a 100644 --- a/frontend/src/pages/settings/FactoryDefaults.tsx +++ b/frontend/src/pages/settings/FactoryDefaults.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import Card from '../../components/ui/Card'; import Button from '../../components/ui/Button'; import { ArrowLeft, Download, + Upload, Package, Info, Loader2, @@ -17,6 +18,7 @@ import { Calendar, FileType, FileText, + FileCode, } from 'lucide-react'; import api from '../../services/api'; @@ -27,6 +29,19 @@ interface PreviewCounts { contractDurations: number; contractCategories: number; pdfTemplates: number; + appSettings: number; +} + +interface ImportResult { + providers: number; + tariffs: number; + cancellationPeriods: number; + contractDurations: number; + contractCategories: number; + pdfTemplates: number; + pdfTemplatesSkipped: number; + appSettings: number; + warnings: string[]; } export default function FactoryDefaults() { @@ -34,6 +49,12 @@ export default function FactoryDefaults() { const [downloadError, setDownloadError] = useState(null); const [downloadDone, setDownloadDone] = useState(false); + const fileInputRef = useRef(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importResult, setImportResult] = useState(null); + const queryClient = useQueryClient(); + const { data: previewData, isLoading } = useQuery({ queryKey: ['factory-defaults-preview'], queryFn: async () => { @@ -86,9 +107,39 @@ export default function FactoryDefaults() { { icon: Calendar, label: 'Laufzeiten', count: counts.contractDurations, color: 'text-pink-600' }, { icon: FileType, label: 'Vertragskategorien', count: counts.contractCategories, color: 'text-orange-600' }, { icon: FileText, label: 'PDF-Auftragsvorlagen', count: counts.pdfTemplates, color: 'text-green-600' }, + { icon: FileCode, label: 'HTML-Templates', count: counts.appSettings ?? 0, color: 'text-teal-600' }, ] : []; + const handleImport = async (file: File) => { + setImporting(true); + setImportError(null); + setImportResult(null); + try { + const formData = new FormData(); + formData.append('zip', file); + const res = await api.post<{ success: boolean; data: ImportResult; error?: string }>( + '/factory-defaults/import', + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ); + if (!res.data.success) { + throw new Error(res.data.error || 'Fehler beim Import'); + } + setImportResult(res.data.data); + // Caches invalidieren – neue Anbieter, Tarife, Vorlagen tauchen sofort + // an anderer Stelle (Provider-Liste, Vertrag-Anlage, …) auf. + queryClient.invalidateQueries(); + } catch (err: any) { + setImportError( + err?.response?.data?.error || err?.message || 'Fehler beim Import', + ); + } finally { + setImporting(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + return (
@@ -109,14 +160,15 @@ export default function FactoryDefaults() {

Was sind Factory-Defaults?

- Das sind reine Stammdaten-Kataloge wie Anbieter, Tarife, - Kündigungsfristen, Vertragskategorien und PDF-Auftragsvorlagen. Du kannst sie - exportieren, um sie in anderen OpenCRM-Installationen als Startpunkt zu - verwenden. + Das sind Stammdaten-Kataloge wie Anbieter, Tarife, + Kündigungsfristen, Vertragskategorien, PDF-Auftragsvorlagen und die + HTML-Standardtexte (Datenschutzerklärung, Impressum, Vollmacht-Vorlage, + Website-Datenschutz). Du kannst sie exportieren, um sie in anderen + OpenCRM-Installationen als Startpunkt zu verwenden.

- Nicht enthalten sind Kundendaten, Verträge, Dokumente, Emails - oder Einstellungen – dafür gibt es den separaten{' '} + Nicht enthalten sind Kundendaten, Verträge, Dokumente, E-Mails + oder Konfigurationen (SMTP, Secrets) – dafür gibt es den separaten{' '} Datenbank-Backup @@ -127,16 +179,10 @@ export default function FactoryDefaults() {

- Erstellt ein ZIP mit allen Kataloge-Daten + PDF-Vorlagen. Lade es herunter und - entpacke den Inhalt in einer anderen Installation unter{' '} - - backend/factory-defaults/ - - , dann dort{' '} - - npm run seed:defaults - {' '} - ausführen. + Erstellt ein ZIP mit allen Kataloge-Daten, PDF-Auftragsvorlagen und den + HTML-Standardtexten (Datenschutz / Impressum / Vollmacht). In einer anderen + OpenCRM-Installation kannst du es dann unten unter Import wieder + einspielen.

{isLoading ? ( @@ -191,34 +237,88 @@ export default function FactoryDefaults() { -
-

- Der Import läuft über ein Kommandozeilen-Script – dadurch bleibt klar, was wann - passiert und es gibt keine ungeplanten Überschreibungen im Produktivbetrieb. -

-
    -
  1. - ZIP in einer beliebigen Installation herunterladen und den Inhalt nach{' '} - - backend/factory-defaults/ - {' '} - entpacken -
  2. -
  3. - Optional mehrere ZIPs zusammenwerfen (JSON-Dateien werden automatisch gemerged) -
  4. -
  5. - Im Backend-Ordner:{' '} - - npm run seed:defaults - -
  6. -
-

- Das Script läuft idempotent – gleiche Einträge werden per - unique-Key aktualisiert, neue hinzugefügt. Kann beliebig oft ausgeführt werden. -

+

+ Lade hier eine zuvor exportierte Factory-Defaults-ZIP hoch. Bestehende Einträge + werden anhand des Unique-Keys (Name / Code) aktualisiert, neue + werden angelegt. Es wird nichts gelöscht – der Vorgang ist idempotent. +

+ + { + const f = e.target.files?.[0]; + if (f) handleImport(f); + }} + /> + +
+ + + Alternativ:{' '} + + npm run seed:defaults + {' '} + im Backend +
+ + {importResult && ( +
+
+ + Import erfolgreich +
+
    +
  • Anbieter: {importResult.providers}
  • +
  • Tarife: {importResult.tariffs}
  • +
  • Kündigungsfristen: {importResult.cancellationPeriods}
  • +
  • Laufzeiten: {importResult.contractDurations}
  • +
  • Vertragskategorien: {importResult.contractCategories}
  • +
  • + PDF-Vorlagen: {importResult.pdfTemplates} + {importResult.pdfTemplatesSkipped > 0 && + ` (${importResult.pdfTemplatesSkipped} übersprungen)`} +
  • +
  • HTML-Templates: {importResult.appSettings}
  • +
+ {importResult.warnings.length > 0 && ( +
+
Hinweise:
+
    + {importResult.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )} +
+ )} + + {importError && ( +
+ + {importError} +
+ )}
);