Files
teOS/apps/api/prisma/migrate-env-to-db.ts
Flexomatic81 6a8265d3dc feat: move configuration from .env to DB with Admin UI management
Replace hardcoded .env configuration with database-backed settings
manageable through the Admin web interface. This reduces .env to
bootstrap-only variables (DB, Keycloak, encryption keys).

Backend:
- Add SystemSetting Prisma model with category, valueType, isSecret
- Add system-settings NestJS module (CRUD, 60s cache, encryption)
- Refactor all 7 connectors to lazy-load credentials from DB via
  CredentialsService.findActiveByType() instead of ConfigService
- Add event-driven credential reload (@nestjs/event-emitter)
- Dynamic CORS origins and conditional Swagger from DB settings
- Fix JWT strategy: use Keycloak JWKS (RS256) instead of symmetric secret
- Add SYSTEM_SETTINGS_VIEW/MANAGE permissions
- Seed 13 default settings (sync intervals, features, branding, CORS)
- Add env-to-db migration script (prisma/migrate-env-to-db.ts)

Frontend:
- Add use-credentials hook (full CRUD for integration credentials)
- Add use-system-settings hook (read/update system settings)
- Wire admin-integrations page to real API (create/update/test/toggle)
- Add admin system-settings page with 4 tabs (Branding, CORS, Sync, Features)
- Fix sidebar double-highlighting with exactMatch flag
- Fix integration detail fallback when API unavailable
- Fix API client to unwrap backend's {success, data} envelope
- Update NEXT_PUBLIC_API_URL to include /v1 version prefix
- Fix activity-widget hydration error
- Add i18n keys for systemSettings (de + en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:07:39 +01:00

310 lines
8.6 KiB
TypeScript

/**
* Migration script: moves integration credentials and configurable settings
* from environment variables into the database.
*
* Run with:
* npx ts-node -P tsconfig.json -e "require('./prisma/migrate-env-to-db.ts')"
*
* Or (with dotenv):
* node -r dotenv/config -r ts-node/register prisma/migrate-env-to-db.ts
*
* The script is idempotent - safe to run multiple times.
*/
import { PrismaClient, IntegrationType } from '@prisma/client';
import * as crypto from 'crypto';
const prisma = new PrismaClient();
// ---------------------------------------------------------------------------
// Encryption helpers - MUST match EncryptionService exactly
// ---------------------------------------------------------------------------
const IV_LENGTH = 16; // 128 bits
const TAG_LENGTH = 16; // 128 bits
const KEY_LENGTH = 32; // 256 bits
/**
* Derives a 256-bit key using PBKDF2 - matches EncryptionService.deriveKey()
*/
function deriveKey(password: string): Buffer {
const salt = 'tos-encryption-salt-v1';
return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256');
}
/**
* Builds the encryption key buffer - matches EncryptionService.onModuleInit()
*/
function buildEncryptionKey(encryptionKey: string): Buffer {
if (encryptionKey.length < KEY_LENGTH) {
return deriveKey(encryptionKey);
}
// Use first 32 UTF-8 bytes when key is long enough
return Buffer.from(encryptionKey.slice(0, KEY_LENGTH), 'utf-8');
}
/**
* Encrypts a JSON object - matches EncryptionService.encryptObject()
*
* Format: base64( IV[16] || ciphertext || authTag[16] )
*/
function encryptObject(data: Record<string, string>, encryptionKeyRaw: string): string {
const json = JSON.stringify(data);
const key = buildEncryptionKey(encryptionKeyRaw);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, {
authTagLength: TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(json, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Combine IV + ciphertext + authTag, encode as base64
const combined = Buffer.concat([iv, encrypted, authTag]);
return combined.toString('base64');
}
// ---------------------------------------------------------------------------
// Migration
// ---------------------------------------------------------------------------
async function migrate() {
console.log('Starting .env to DB migration...\n');
// 1. Migrate system settings
const settingsMap: Record<
string,
{ key: string; category: string; valueType: string; description: string }
> = {
ENABLE_SYNC_JOBS: {
key: 'feature.syncJobs.enabled',
category: 'feature',
valueType: 'boolean',
description: 'Hintergrund-Sync-Jobs aktivieren',
},
SWAGGER_ENABLED: {
key: 'feature.swagger.enabled',
category: 'feature',
valueType: 'boolean',
description: 'Swagger API-Dokumentation aktivieren',
},
CORS_ORIGINS: {
key: 'cors.origins',
category: 'cors',
valueType: 'string',
description: 'Erlaubte CORS Origins (kommagetrennt)',
},
SYNC_INTERVAL_PLENTYONE: {
key: 'sync.interval.plentyone',
category: 'sync',
valueType: 'number',
description: 'PlentyONE Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_ZULIP: {
key: 'sync.interval.zulip',
category: 'sync',
valueType: 'number',
description: 'Zulip Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_TODOIST: {
key: 'sync.interval.todoist',
category: 'sync',
valueType: 'number',
description: 'Todoist Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_FREESCOUT: {
key: 'sync.interval.freescout',
category: 'sync',
valueType: 'number',
description: 'FreeScout Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_NEXTCLOUD: {
key: 'sync.interval.nextcloud',
category: 'sync',
valueType: 'number',
description: 'Nextcloud Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_ECODMS: {
key: 'sync.interval.ecodms',
category: 'sync',
valueType: 'number',
description: 'ecoDMS Sync-Intervall (Minuten)',
},
SYNC_INTERVAL_GEMBADOCS: {
key: 'sync.interval.gembadocs',
category: 'sync',
valueType: 'number',
description: 'GembaDocs Sync-Intervall (Minuten)',
},
};
console.log(' Migrating system settings...');
for (const [envKey, setting] of Object.entries(settingsMap)) {
const envValue = process.env[envKey];
if (envValue !== undefined) {
await prisma.systemSetting.upsert({
where: { key: setting.key },
update: { value: envValue },
create: {
key: setting.key,
value: envValue,
category: setting.category,
valueType: setting.valueType,
description: setting.description,
},
});
console.log(` Migrated ${envKey} -> ${setting.key} = ${envValue}`);
} else {
console.log(` Skipped ${envKey} (not set in environment)`);
}
}
// 2. Migrate integration credentials
const encryptionKey = process.env.ENCRYPTION_KEY;
if (!encryptionKey) {
console.warn(
'\n WARNING: ENCRYPTION_KEY not set. Skipping credential migration.',
);
console.log('\nDone (settings only).');
await prisma.$disconnect();
return;
}
// Find admin user for createdById
const adminUser = await prisma.user.findFirst({
where: { roles: { some: { role: { name: 'admin' } } } },
});
if (!adminUser) {
console.warn(
'\n WARNING: No admin user found. Skipping credential migration.',
);
console.log('\nDone (settings only).');
await prisma.$disconnect();
return;
}
const integrations: Array<{
type: IntegrationType;
name: string;
envMap: Record<string, string>;
}> = [
{
type: IntegrationType.PLENTYONE,
name: 'PlentyONE Default',
envMap: {
baseUrl: 'PLENTYONE_BASE_URL',
clientId: 'PLENTYONE_CLIENT_ID',
clientSecret: 'PLENTYONE_CLIENT_SECRET',
},
},
{
type: IntegrationType.ZULIP,
name: 'Zulip Default',
envMap: {
baseUrl: 'ZULIP_BASE_URL',
email: 'ZULIP_EMAIL',
apiKey: 'ZULIP_API_KEY',
},
},
{
type: IntegrationType.TODOIST,
name: 'Todoist Default',
envMap: {
apiToken: 'TODOIST_API_TOKEN',
},
},
{
type: IntegrationType.FREESCOUT,
name: 'FreeScout Default',
envMap: {
apiUrl: 'FREESCOUT_API_URL',
apiKey: 'FREESCOUT_API_KEY',
},
},
{
type: IntegrationType.NEXTCLOUD,
name: 'Nextcloud Default',
envMap: {
serverUrl: 'NEXTCLOUD_URL',
username: 'NEXTCLOUD_USERNAME',
password: 'NEXTCLOUD_PASSWORD',
},
},
{
type: IntegrationType.ECODMS,
name: 'ecoDMS Default',
envMap: {
apiUrl: 'ECODMS_API_URL',
username: 'ECODMS_USERNAME',
password: 'ECODMS_PASSWORD',
},
},
{
type: IntegrationType.GEMBADOCS,
name: 'GembaDocs Default',
envMap: {
apiUrl: 'GEMBADOCS_API_URL',
apiKey: 'GEMBADOCS_API_KEY',
},
},
];
console.log('\n Migrating integration credentials...');
for (const integration of integrations) {
const creds: Record<string, string> = {};
let hasValues = false;
for (const [credKey, envKey] of Object.entries(integration.envMap)) {
const val = process.env[envKey];
if (val) {
creds[credKey] = val;
hasValues = true;
}
}
if (hasValues) {
const encrypted = encryptObject(creds, encryptionKey);
const existing = await prisma.integrationCredential.findFirst({
where: { type: integration.type, name: integration.name },
});
if (existing) {
await prisma.integrationCredential.update({
where: { id: existing.id },
data: { credentials: encrypted },
});
console.log(` Updated ${integration.type} credentials`);
} else {
await prisma.integrationCredential.create({
data: {
type: integration.type,
name: integration.name,
credentials: encrypted,
createdById: adminUser.id,
},
});
console.log(` Created ${integration.type} credentials`);
}
} else {
console.log(` Skipped ${integration.type} (no env vars set)`);
}
}
console.log('\nMigration complete!');
await prisma.$disconnect();
}
migrate().catch((error) => {
console.error('Migration failed:', error);
prisma.$disconnect();
process.exit(1);
});