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>
This commit is contained in:
309
apps/api/prisma/migrate-env-to-db.ts
Normal file
309
apps/api/prisma/migrate-env-to-db.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user