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>
310 lines
8.6 KiB
TypeScript
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);
|
|
});
|