Merge pull request #731 from Luca-Timo/feat/setup-wizard-and-backup-roundtrip

feat: first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip
This commit is contained in:
Paul Nothaft
2026-07-03 10:03:22 +02:00
committed by GitHub
12 changed files with 1558 additions and 26 deletions
@@ -0,0 +1,94 @@
'use strict';
// Validates the engine-neutral .picpeak export: it must produce a real zip with
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const StreamZip = require('node-stream-zip');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
// bootCrmDb MUST run before requiring the service (which transitively requires
// db.js) so the export reads this test's DB, not the default path.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 60000);
afterAll(async () => {
await cleanup();
});
async function readZip(filePath) {
const zip = new StreamZip.async({ file: filePath });
const entries = Object.keys(await zip.entries());
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
await zip.close();
return { entries, manifest };
}
describe('picpeak export (.picpeak logical export)', () => {
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
expect(filePath.endsWith('.picpeak')).toBe(true);
expect(fs.existsSync(filePath)).toBe(true);
expect(manifest.format).toBe(1);
expect(manifest.kind).toBe('picpeak-backup');
expect(manifest.database.engine).toBe('sqlite');
expect(manifest.options.includePhotos).toBe(false);
expect(manifest.contains_secrets).toBe(true);
// Migrations seed real tables (e.g. app_settings) — expect several.
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
expect(Object.keys(manifest.tables)).toContain('app_settings');
const { entries, manifest: zipped } = await readZip(filePath);
expect(entries).toContain('manifest.json');
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
expect(entries).toContain('data/app_settings.ndjson');
// Manifest inside the zip matches the returned one.
expect(zipped.tables).toEqual(manifest.tables);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('never exports knex bookkeeping tables', async () => {
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const names = Object.keys(manifest.tables);
expect(names).not.toContain('knex_migrations');
expect(names).not.toContain('knex_migrations_lock');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('row counts in the manifest match the NDJSON line counts', async () => {
// Insert a couple of settings so at least one table is non-empty.
await db('app_settings')
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
.onConflict('setting_key').merge();
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
try {
const zip = new StreamZip.async({ file: filePath });
const buf = await zip.entryData('data/app_settings.ndjson');
await zip.close();
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
});
@@ -0,0 +1,180 @@
'use strict';
// Full .picpeak roundtrip on a temp SQLite DB:
// 1. seed a "backup" instance (admin A + a marker setting)
// 2. export → .picpeak
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
// 4. import the backup with currentAdminId = B
// 5. assert the backup data is restored AND the current account (B) survives,
// while the backup's admin (A) is also present (different email → added).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let createPicpeak;
let importFromPicpeak;
let validateManifest;
let superAdminRoleId;
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir;
({ createPicpeak } = require('../../src/services/picpeakExportService'));
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 60000);
afterAll(async () => {
await cleanup();
});
const adminRow = (email, hash) => ({
username: email,
email,
password_hash: hash,
role_id: superAdminRoleId,
is_active: true,
must_change_password: false,
created_at: new Date(),
updated_at: new Date(),
});
async function setMarker(value) {
await db('app_settings')
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
.onConflict('setting_key').merge();
}
async function getMarker() {
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
return row ? JSON.parse(row.setting_value) : null;
}
describe('.picpeak roundtrip (export → import)', () => {
it('restores backup data and preserves the current account', async () => {
// 1. Seed the "source" instance.
await db('admin_users').del();
await db('admin_users').insert(adminRow('[email protected]', 'HASH_A'));
await setMarker('from_backup');
// 2. Export.
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// 3. Simulate a reinstall: fresh current admin B, mutated data.
await db('admin_users').del();
const [bId] = await db('admin_users').insert(adminRow('[email protected]', 'HASH_B')).returning('id');
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
await setMarker('mutated_after_backup');
// 4. Import, preserving the current admin.
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
expect(result.restored).toBe(true);
expect(result.tables).toBeGreaterThan(0);
// 5a. Backup data restored (marker reverted to the backup value).
expect(await getMarker()).toBe('from_backup');
// 5b. The backup's admin is present (different email → added).
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['[email protected]']).first();
expect(a).toBeTruthy();
expect(a.password_hash).toBe('HASH_A');
// 5c. The current account SURVIVES the override, with its own credentials.
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['[email protected]']).first();
expect(b).toBeTruthy();
expect(b.password_hash).toBe('HASH_B');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('overwrites a backup admin that collides with the current account email', async () => {
// Source has an admin at the SAME email the current operator will use.
await db('admin_users').del();
await db('admin_users').insert(adminRow('[email protected]', 'OLD_HASH'));
await setMarker('collision_case');
const { filePath } = await createPicpeak({ includePhotos: false });
try {
// Reinstall: current admin uses the same email but a NEW password.
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('[email protected]', 'NEW_HASH')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
// Exactly one admin at that email, and it keeps the CURRENT password.
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['[email protected]']);
expect(rows).toHaveLength(1);
expect(rows[0].password_hash).toBe('NEW_HASH');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
}
});
it('restores files/ and reports filesRestored', async () => {
// A business-doc that lives in storage → travels in the backup.
const docDir = path.join(tmpDir, 'business-docs');
const marker = path.join(docDir, 'roundtrip-doc.txt');
fs.mkdirSync(docDir, { recursive: true });
fs.writeFileSync(marker, 'hello');
await db('admin_users').del();
const [id] = await db('admin_users').insert(adminRow('[email protected]', 'H')).returning('id');
const currentAdminId = typeof id === 'object' ? id.id : id;
const { filePath } = await createPicpeak({ includePhotos: false });
try {
fs.rmSync(marker); // delete on disk so the restore must bring it back
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
expect(fs.existsSync(marker)).toBe(true);
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
} finally {
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
fs.rmSync(docDir, { recursive: true, force: true });
}
});
});
describe('.picpeak manifest validation', () => {
it('rejects a database-engine mismatch', async () => {
// Harness runs on SQLite, so a pg manifest must be refused.
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
});
it('rejects a backup from a newer schema (forward-only)', async () => {
// validateManifest reads knex_migrations for the target's latest migration;
// the harness has none, so create it with an older migration than the backup.
await db.schema.createTable('knex_migrations', (t) => {
t.increments('id');
t.string('name');
t.integer('batch');
t.timestamp('migration_time');
});
try {
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1,
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
tables: {},
});
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
} finally {
await db.schema.dropTableIfExists('knex_migrations');
}
});
it('rejects a file that is not a PicPeak backup', async () => {
const blockers = await validateManifest({ some: 'random-json' });
expect(blockers.length).toBeGreaterThan(0);
});
});
+65
View File
@@ -131,6 +131,71 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
}
});
// Generate + download a portable ".picpeak" export — an engine-neutral logical
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
// another instance via the web UI. `?includePhotos=true` also bundles original
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
//
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
// hashes, API keys). The download UI must warn before offering it. We surface
// the flag as a response header too so the client can double-confirm.
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
const fsSync = require('fs');
try {
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
const { createPicpeak } = require('../services/picpeakExportService');
const { filePath } = await createPicpeak({ includePhotos });
const filename = path.basename(filePath);
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
res.download(filePath, filename, (err) => {
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
});
} catch (error) {
logger.error('[picpeak-export] failed to create export', { error: error.message });
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
}
});
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
// auth so an unauthenticated request can't push a large file to disk.
const os = require('os');
const multer = require('multer');
const picpeakUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, os.tmpdir()),
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
}),
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
});
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
// all data except the current logged-in account (the client shows an explicit
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
// prompt the admin to reconfigure the external-media mount afterwards.
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
const fsSync = require('fs');
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
const picpeakPath = req.file.path;
try {
const { importFromPicpeak } = require('../services/picpeakImportService');
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
res.json({
success: true,
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
});
} catch (error) {
const status = error.statusCode || 500;
logger.error('[picpeak-import] restore failed', { error: error.message });
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
} finally {
fsSync.unlink(picpeakPath, () => {});
}
});
// Get backup run details
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
@@ -0,0 +1,231 @@
'use strict';
// Portable ".picpeak" export — a single, self-describing archive that can be
// downloaded from one instance and re-uploaded to another via the web UI only
// (see picpeakImportService for the receiving half).
//
// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup
// (which can only ever restore into the same engine and version), each table is
// written as NDJSON. The target rebuilds its own schema by running migrations,
// then loads these rows into it — so an older backup restores cleanly onto a
// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work.
//
// This module is purely additive: it introduces a new artifact and touches no
// existing backup/restore path.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const archiver = require('archiver');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const logger = require('../utils/logger');
const packageJson = require('../../package.json');
// Bump only on a breaking change to the on-disk layout below.
const PICPEAK_FORMAT_VERSION = 1;
// Never exported as data — the target owns these (its own migrations set them).
const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']);
// Storage subdirs holding non-recalculable blobs — always included.
const DOC_DIRS = ['business-docs', 'uploads'];
// Original gallery photos — only when includePhotos is true (large; otherwise
// the admin re-uploads originals per gallery and previews are re-rendered).
const PHOTO_DIRS = ['events/active', 'events/archived'];
const isPostgres = () => knexConfig.client === 'pg';
// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite.
const rawRows = (result) => (isPostgres() ? result.rows : result);
// All user tables, minus knex bookkeeping. Introspected at runtime so the
// export never rots as tables are added (no hardcoded list to maintain).
async function listDataTables() {
let names;
if (isPostgres()) {
const result = await db.raw(`
SELECT table_name AS name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
names = rawRows(result).map((r) => r.name);
} else {
const result = await db.raw(`
SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`);
names = rawRows(result).map((r) => r.name);
}
return names.filter((n) => !EXCLUDED_TABLES.has(n));
}
// The latest applied migration — recorded in the manifest so the importer can
// refuse a backup that is NEWER than the target (forward-only guarantee).
async function getLatestMigration() {
try {
const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1);
return rows[0]?.name || null;
} catch (_) {
return null;
}
}
// Write one table to <dataDir>/<table>.ndjson (one JSON object per line).
// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises
// Dates to ISO strings, which re-import cleanly on both engines.
//
// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls
// in the optional `pg-query-stream` dependency (not bundled), so it throws on
// pg. A select works on both engines with no extra dependency. Rows are DB
// metadata (blobs live on disk under files/), so holding a table in memory is
// fine for the instance sizes PicPeak targets.
async function writeTableNdjson(table, dataDir) {
const outPath = path.join(dataDir, `${table}.ndjson`);
const hash = crypto.createHash('sha256');
const rows = await db(table).select('*');
const lines = rows.map((row) => {
const line = JSON.stringify(row);
hash.update(`${line}\n`);
return line;
});
await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8');
return { rowCount: rows.length, checksum: hash.digest('hex') };
}
// Recursively collect files under a storage subdir as { abs, rel } where rel is
// relative to the storage root (so the importer restores the same layout).
async function collectDir(subdir, storageRoot, acc) {
const abs = path.join(storageRoot, subdir);
let entries;
try {
entries = await fsp.readdir(abs, { withFileTypes: true });
} catch (_) {
return; // subdir may not exist on this install — skip silently
}
for (const entry of entries) {
const childRel = path.join(subdir, entry.name);
if (entry.isDirectory()) {
await collectDir(childRel, storageRoot, acc);
} else if (entry.isFile()) {
acc.push({ abs: path.join(storageRoot, childRel), rel: childRel });
}
}
}
async function collectFiles(includePhotos) {
const storageRoot = getStoragePath();
const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS];
const acc = [];
for (const d of dirs) {
await collectDir(d, storageRoot, acc);
}
return acc;
}
/**
* Build a .picpeak archive.
* @param {Object} opts
* @param {boolean} [opts.includePhotos=false] include original gallery photos
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
* @returns {Promise<{ filePath: string, manifest: object }>}
*/
async function createPicpeak({ includePhotos = false, outDir } = {}) {
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
const dataDir = path.join(staging, 'data');
await fsp.mkdir(dataDir, { recursive: true });
try {
// 1. Dump every table to NDJSON, tracking counts + checksums.
const tables = await listDataTables();
const tableMeta = {};
for (const table of tables) {
tableMeta[table] = await writeTableNdjson(table, dataDir);
}
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
// optionally original photos).
const files = await collectFiles(includePhotos);
// 3. Manifest — everything the importer needs to validate + reconstruct.
const manifest = {
format: PICPEAK_FORMAT_VERSION,
kind: 'picpeak-backup',
created_at: new Date().toISOString(),
app_version: packageJson.version || null,
database: {
engine: isPostgres() ? 'pg' : 'sqlite',
latest_migration: await getLatestMigration(),
},
options: { includePhotos: !!includePhotos },
tables: tableMeta,
file_count: files.length,
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
// text — the download surface must warn about this.
contains_secrets: true,
};
await fsp.writeFile(
path.join(staging, 'manifest.json'),
JSON.stringify(manifest, null, 2),
'utf8'
);
// 4. Zip staging (manifest + data/) plus the blobs under files/. The final
// .picpeak lands in outDir (caller-managed) or a fresh temp dir; either
// way the NDJSON scratch (which holds plaintext secrets) is always
// removed in `finally` below.
const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-')));
await fsp.mkdir(targetDir, { recursive: true });
const stamp = manifest.created_at.replace(/[:.]/g, '-');
const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`);
try {
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(filePath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
// Surface archiver warnings (e.g. a file vanished mid-run) instead of
// silently shipping an incomplete archive.
archive.on('warning', (err) => reject(err));
archive.pipe(output);
archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' });
archive.directory(dataDir, 'data');
for (const f of files) {
archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) });
}
archive.finalize();
});
} catch (err) {
// Archiver failed → the partial .picpeak holds plaintext secrets and is
// useless; remove our own temp out dir so it isn't orphaned. A
// caller-supplied outDir is left untouched.
if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
logger.info(
`[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})`
);
return { filePath, manifest };
} finally {
// Always remove the NDJSON scratch dir — it contains a plaintext dump of
// every table (secrets included). The final .picpeak is elsewhere.
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
PICPEAK_FORMAT_VERSION,
EXCLUDED_TABLES,
createPicpeak,
// exported for reuse/testing
listDataTables,
collectFiles,
};
@@ -0,0 +1,271 @@
'use strict';
// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced
// by picpeakExportService and restores it onto THIS instance.
//
// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and
// replaced by the backup's rows — EXCEPT the current logged-in admin account,
// which is preserved so the operator is never locked out. A backup admin whose
// email collides with the current account is overwritten with the current
// account's credentials (so the operator's known password keeps working).
//
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
// restores onto a newer instance; a newer backup is refused). The target's own
// schema is used as-is — we never replay the backup's DDL.
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const StreamZip = require('node-stream-zip');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
const isPostgres = () => knexConfig.client === 'pg';
// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …).
function migrationOrder(name) {
const m = String(name || '').match(/^(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}
async function readManifestFromZip(picpeakPath) {
const zip = new StreamZip.async({ file: picpeakPath });
try {
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
} finally {
await zip.close();
}
}
// Returns an array of human-readable blockers ([] = OK to restore).
async function validateManifest(manifest) {
const errors = [];
if (!manifest || manifest.kind !== 'picpeak-backup') {
return ['This file is not a PicPeak backup (.picpeak).'];
}
if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) {
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
}
const engine = isPostgres() ? 'pg' : 'sqlite';
if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
}
// Forward-only: the target schema must be at least as new as the backup's.
let targetLatest = null;
try {
const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1);
targetLatest = applied[0] ? applied[0].name : null;
} catch (_) {
// No knex_migrations table (e.g. some test harnesses) — skip the check.
}
const backupLatest = manifest.database ? manifest.database.latest_migration : null;
if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) {
errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.');
}
return errors;
}
function parseNdjson(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs
.readFileSync(filePath, 'utf8')
.split('\n')
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l));
}
// Re-insert the operator's account inside the restore transaction so they keep
// working credentials. If the backup already loaded an admin with the same
// email, overwrite that row's credentials with the current account's (current
// creds win); otherwise insert the snapshot with a fresh id.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return;
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
if (existing) {
await trx('admin_users').where({ id: existing.id }).update({
password_hash: currentAdmin.password_hash,
is_active: currentAdmin.is_active,
must_change_password: currentAdmin.must_change_password,
});
} else {
const row = { ...currentAdmin };
delete row.id; // let the engine assign a fresh id to avoid collision
await trx('admin_users').insert(row);
}
}
// The json/jsonb columns of a table (Postgres only). The pg driver returns
// jsonb as parsed JS values, so on re-insert they must be serialised back to
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
// unquoted and pg rejects it ("invalid input syntax for type json").
async function jsonColumnsFor(trx, table) {
if (!isPostgres()) return new Set();
const res = await trx.raw(
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
[table]
);
return new Set(res.rows.map((r) => r.column_name));
}
function serialiseJsonColumns(rows, jsonCols) {
if (!jsonCols.size) return rows;
return rows.map((row) => {
const out = { ...row };
for (const col of jsonCols) {
if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]);
}
return out;
});
}
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
await trx.raw("SET session_replication_role = 'replica'");
} catch (_) {
// session_replication_role requires a Postgres SUPERUSER. The bundled
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
// app users usually are not. Fail fast with a clear message BEFORE any
// rows are deleted — the transaction rolls back, so nothing is wiped.
const err = new Error(
'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instances database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.'
);
err.statusCode = 400;
throw err;
}
} else {
await trx.raw('PRAGMA defer_foreign_keys = ON');
}
for (const table of tables) {
await trx(table).del();
}
for (const table of tables) {
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
if (!rows.length) continue;
const jsonCols = await jsonColumnsFor(trx, table);
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
});
}
// Copy the archive's files/ tree into storage, overwriting existing files.
async function restoreFiles(stagingDir) {
const src = path.join(stagingDir, 'files');
if (!fs.existsSync(src)) return 0;
const storageRoot = getStoragePath();
let count = 0;
async function walk(rel) {
const abs = path.join(src, rel);
for (const entry of await fsp.readdir(abs, { withFileTypes: true })) {
const childRel = path.join(rel, entry.name);
if (entry.isDirectory()) {
await walk(childRel);
} else if (entry.isFile()) {
const dest = path.join(storageRoot, childRel);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.copyFile(path.join(src, childRel), dest);
count += 1;
}
}
}
await walk('');
return count;
}
// Does the restored data reference an external-media library? If so the caller
// shows a banner telling the admin to (re)configure the external-media mount on
// this instance — those files are NOT in the backup by design.
async function detectExternalMedia() {
try {
if (await hasColumnCached('events', 'external_path')) {
const row = await db('events').whereNotNull('external_path').first();
if (row) return true;
}
if (await hasColumnCached('photos', 'external_relpath')) {
const row = await db('photos').whereNotNull('external_relpath').first();
if (row) return true;
}
} catch (_) {
// Best-effort — a detection miss is not worth failing the restore.
}
return false;
}
/**
* Restore a .picpeak onto this instance.
* @param {Object} opts
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
*/
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const manifest = await readManifestFromZip(picpeakPath);
const blockers = await validateManifest(manifest);
if (blockers.length) {
const err = new Error(blockers[0]);
err.statusCode = 400;
err.validation = blockers;
throw err;
}
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
const zip = new StreamZip.async({ file: picpeakPath });
try {
await zip.extract(null, staging);
} finally {
await zip.close();
}
const dataDir = path.join(staging, 'data');
// Only touch tables that (a) the uploaded manifest lists AND (b) actually
// exist as real tables in THIS database. listDataTables() already excludes
// knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted
// .picpeak can never make the restore delete the migration bookkeeping — or
// any table that isn't a genuine data table here.
const dbTables = new Set(await listDataTables());
const manifestTables = Object.keys(manifest.tables || {});
const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl));
const skipped = manifestTables.filter((tbl) => !tables.includes(tbl));
if (skipped.length) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
logger.info(
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
);
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
} finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
};
@@ -0,0 +1,219 @@
import React, { useRef, useState } from 'react';
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
import { api } from '../../config/api';
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
// - PicpeakExportCard → Dashboard (making a backup)
// - PicpeakRestoreCard → Restore (restoring a backup)
// The manifest is bundled inside the .picpeak, so there is no separate
// "manifest only" download here.
interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
export const PicpeakExportCard: React.FC = () => {
const { t } = useTranslation();
const [includePhotos, setIncludePhotos] = useState(false);
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
const res = await api.get('/admin/backup/picpeak/export', {
params: { includePhotos },
responseType: 'blob',
});
const cd = (res.headers['content-disposition'] as string) || '';
const match = cd.match(/filename="?([^"]+)"?/);
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
const url = window.URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_) {
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
} finally {
setDownloading(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
</p>
<div className="mt-6">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-neutral-300"
checked={includePhotos}
onChange={(e) => setIncludePhotos(e.target.checked)}
/>
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
</label>
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-800 dark:text-amber-200">
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
</p>
</div>
<Button
variant="outline"
className="mt-3"
isLoading={downloading}
onClick={handleDownload}
leftIcon={<Download className="h-4 w-4" />}
>
{t('backup.picpeak.download', 'Download .picpeak')}
</Button>
</div>
</Card>
);
};
PicpeakExportCard.displayName = 'PicpeakExportCard';
// ── Restore half (Restore tab) ───────────────────────────────────────────────
export const PicpeakRestoreCard: React.FC = () => {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [restoring, setRestoring] = useState(false);
const [result, setResult] = useState<RestoreResult | null>(null);
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
e.target.value = ''; // let the user re-pick the same file after cancelling
};
const confirmRestore = async () => {
if (!pendingFile) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('backup', pendingFile);
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);
setPendingFile(null);
} finally {
setRestoring(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
variant="outline"
className="mt-4"
onClick={() => fileRef.current?.click()}
leftIcon={<Upload className="h-4 w-4" />}
>
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
</Button>
{result && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.picpeak.restoreDone', 'Backup restored.')}
</p>
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
tables: result.tables,
files: result.filesRestored,
})}
</p>
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
<ExternalLink className="h-3 w-3" />
</a>
</span>
</p>
)}
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
{t('backup.picpeak.reload', 'Reload app')}
</Button>
</div>
</div>
</div>
)}
{/* Destructive confirmation */}
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
</p>
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
className="!bg-red-600 hover:!bg-red-700"
isLoading={restoring}
onClick={confirmRestore}
>
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
</Button>
</div>
</div>
</div>
)}
</Card>
);
};
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PicpeakRestoreCard } from './PicpeakBackupCard';
import {
RefreshCw,
AlertTriangle,
@@ -283,12 +284,47 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
)}
{restoreData.source === 'upload' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
<div className="space-y-4">
{/* Two upload kinds: the working portable .picpeak restore, and the
legacy manifest+files upload (still a stub). */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'picpeak' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'picpeak'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<FileArchive className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'picpeak' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.picpeak.name', '.picpeak backup')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.picpeak.description', 'Portable full backup — restores everything (full override, keeps your current account).')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'manifest' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'manifest'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Upload className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'manifest' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.manifest.name', 'Manifest + files')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.manifest.description', 'Upload a manifest and its backup files (legacy format).')}</p>
</button>
</div>
</Card>
{restoreData.uploadType === 'picpeak' && <PicpeakRestoreCard />}
{restoreData.uploadType === 'manifest' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}</p>
</div>
</Card>
)}
</div>
)}
</div>
);
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldAlert } from 'lucide-react';
import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service';
// Features that need working SMTP to deliver anything.
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills'];
interface Props {
selectedFeatures: Set<FeatureKey>;
onDone: () => void;
}
// Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is
// best-effort per section — a failure never traps the user on setup.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({
companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '',
vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => {
setSaving(true);
try {
// Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) {
await businessProfileService.update({
companyName: inv.companyName.trim(),
addressLine1: inv.addressLine1.trim(),
postalCode: inv.postalCode.trim(),
city: inv.city.trim(),
countryCode: inv.countryCode.trim(),
vatId: inv.vatId.trim(),
taxId: inv.taxId.trim(),
defaultCurrency: inv.defaultCurrency.trim() || 'CHF',
});
if (inv.iban.trim()) {
await businessProfileService.createBankAccount({
iban: inv.iban.replace(/\s+/g, ''),
accountHolder: inv.companyName.trim(),
currency: inv.defaultCurrency.trim() || 'CHF',
isDefault: true,
});
}
}
// Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(),
smtp_port: port,
smtp_secure: port === 465,
smtp_user: mail.smtp_user.trim(),
smtp_pass: mail.smtp_pass,
from_email: mail.from_email.trim(),
from_name: mail.from_name.trim(),
tls_reject_unauthorized: true,
};
await emailService.updateConfig(config);
}
} catch (_) {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.'));
} finally {
setSaving(false);
onDone();
}
};
return (
<div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')}
</p>
{showInvoicing && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<p className="text-xs text-amber-800">
{t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')}
</p>
</div>
<Input placeholder={t('setup.config.companyName', 'Company / legal name')} value={inv.companyName} onChange={invField('companyName')} />
<Input placeholder={t('setup.config.addressLine1', 'Street and number')} value={inv.addressLine1} onChange={invField('addressLine1')} />
<div className="grid grid-cols-3 gap-3">
<Input placeholder={t('setup.config.postalCode', 'Postal code')} value={inv.postalCode} onChange={invField('postalCode')} />
<div className="col-span-2"><Input placeholder={t('setup.config.city', 'City')} value={inv.city} onChange={invField('city')} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.countryCode', 'Country code (e.g. CH)')} value={inv.countryCode} onChange={invField('countryCode')} />
<Input placeholder={t('setup.config.currency', 'Currency (e.g. CHF)')} value={inv.defaultCurrency} onChange={invField('defaultCurrency')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.vatId', 'VAT ID (or leave blank)')} value={inv.vatId} onChange={invField('vatId')} />
<Input placeholder={t('setup.config.taxId', 'Tax number (or VAT ID)')} value={inv.taxId} onChange={invField('taxId')} />
</div>
<Input placeholder={t('setup.config.iban', 'IBAN (for invoice payments)')} value={inv.iban} onChange={invField('iban')} />
</div>
)}
{showEmail && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} />
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}>
{t('setup.config.skip', 'Skip for now')}
</Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
{t('setup.config.finish', 'Finish setup')}
</Button>
</div>
</div>
);
};
SetupConfigStep.displayName = 'SetupConfigStep';
+70 -2
View File
@@ -634,7 +634,16 @@
"upload": {
"name": "Backup hochladen",
"description": "Eine Backup-Datei hochladen",
"comingSoon": "Upload-Funktion kommt bald"
"comingSoon": "Upload-Funktion kommt bald",
"manifestComingSoon": "Manifest-Upload-Funktion kommt bald",
"picpeak": {
"name": ".picpeak-Backup",
"description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)."
},
"manifest": {
"name": "Manifest + Dateien",
"description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)."
}
},
"configuration": {
"s3": "S3-Konfiguration",
@@ -1779,7 +1788,27 @@
"title": "E-Mail-Einstellungen"
},
"backup": {
"title": "Backup"
"title": "Backup",
"picpeak": {
"title": "Portables Backup (.picpeak)",
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
"download": ".picpeak herunterladen",
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
"restoreTitle": "Aus einer .picpeak wiederherstellen",
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
"chooseFile": ".picpeak-Datei auswählen…",
"restoreDone": "Backup wiederhergestellt.",
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
"externalMediaLink": "Einrichtungsanleitung",
"reload": "App neu laden",
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
"confirmRestore": "Löschen & wiederherstellen"
}
},
"branding": {
"title": "Branding"
@@ -3467,6 +3496,45 @@
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
"usageSubtitle": "Wie möchten Sie PicPeak nutzen?",
"usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.",
"usageGroupCrm": "Kundenverwaltung",
"usageGroupAccounting": "Buchhaltung",
"usageGroupAutomation": "Automatisierung & Versand",
"usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.",
"usageSkip": "Nur mit Galerien fortfahren",
"finish": "Einrichtung abschließen",
"featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.",
"restoreStepSubtitle": "Aus einem Backup wiederherstellen",
"restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?",
"restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.",
"restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.",
"config": {
"subtitle": "Richten Sie Ihre Funktionen ein",
"intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.",
"invoicing": "Rechnungsdaten",
"invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.",
"companyName": "Firma / rechtlicher Name",
"addressLine1": "Strasse und Nummer",
"postalCode": "PLZ",
"city": "Ort",
"countryCode": "Ländercode (z. B. CH)",
"currency": "Währung (z. B. CHF)",
"vatId": "MwSt-Nummer (oder leer lassen)",
"taxId": "Steuernummer (oder MwSt-Nummer)",
"iban": "IBAN (für Rechnungszahlungen)",
"email": "E-Mail-Versand (SMTP)",
"emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.",
"smtpHost": "SMTP-Host",
"smtpPort": "Port",
"smtpUser": "Benutzername",
"smtpPass": "Passwort",
"fromEmail": "Absenderadresse",
"fromName": "Absendername",
"skip": "Vorerst überspringen",
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter",
"back": "Zurück",
+70 -2
View File
@@ -1335,7 +1335,27 @@
"title": "Email Settings"
},
"backup": {
"title": "Backup"
"title": "Backup",
"picpeak": {
"title": "Portable backup (.picpeak)",
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
"includePhotos": "Include original gallery photos (larger file)",
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
"download": "Download .picpeak",
"downloadFailed": "Could not create the backup file.",
"restoreTitle": "Restore from a .picpeak",
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
"chooseFile": "Choose .picpeak file…",
"restoreDone": "Backup restored.",
"restoreFailed": "Restore failed.",
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
"externalMediaLink": "Setup guide",
"reload": "Reload app",
"confirmTitle": "Restore will delete all current data",
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
"confirmRestore": "Delete & restore"
}
},
"branding": {
"title": "Branding"
@@ -3002,7 +3022,16 @@
"upload": {
"name": "Upload Backup",
"description": "Upload a backup file",
"comingSoon": "Upload functionality coming soon"
"comingSoon": "Upload functionality coming soon",
"manifestComingSoon": "Manifest Upload functionality coming soon",
"picpeak": {
"name": ".picpeak backup",
"description": "Portable full backup — restores everything (full override, keeps your current account)."
},
"manifest": {
"name": "Manifest + files",
"description": "Upload a manifest and its backup files (legacy format)."
}
},
"configuration": {
"s3": "S3 Configuration",
@@ -3363,6 +3392,45 @@
"subtitle": "Create your administrator account to get started",
"tokenStepSubtitle": "First, enter your one-time setup token",
"accountStepSubtitle": "Now create your administrator account",
"usageSubtitle": "How will you use PicPeak?",
"usageAlwaysOn": "Galleries, analytics and user management are always included. Pick any extras below — you can change these anytime in Settings.",
"usageGroupCrm": "Client management",
"usageGroupAccounting": "Accounting",
"usageGroupAutomation": "Automation & delivery",
"usageDepsNote": "Invoices automatically enable the Accounting area.",
"usageSkip": "Continue with galleries only",
"finish": "Finish setup",
"featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.",
"restoreStepSubtitle": "Restore from a backup",
"restoreEntry": "Migrating from another PicPeak?",
"restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.",
"restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.",
"config": {
"subtitle": "Set up your features",
"intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.",
"invoicing": "Invoicing details",
"invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.",
"companyName": "Company / legal name",
"addressLine1": "Street and number",
"postalCode": "Postal code",
"city": "City",
"countryCode": "Country code (e.g. CH)",
"currency": "Currency (e.g. CHF)",
"vatId": "VAT ID (or leave blank)",
"taxId": "Tax number (or VAT ID)",
"iban": "IBAN (for invoice payments)",
"email": "Email delivery (SMTP)",
"emailHint": "Required to send reminders, invoices and notifications.",
"smtpHost": "SMTP host",
"smtpPort": "Port",
"smtpUser": "Username",
"smtpPass": "Password",
"fromEmail": "From address",
"fromName": "From name",
"skip": "Skip for now",
"finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
},
"stepOf": "Step {{current}} of {{total}}",
"continue": "Continue",
"back": "Back",
+154 -11
View File
@@ -8,6 +8,9 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../components/common';
import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types';
@@ -16,6 +19,19 @@ import type { AdminUser } from '../types';
const SETUP_DOCS_URL =
'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account';
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin
// account is created. galleries/analytics/userManagement are always on and not
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys
// (`settings.features.<key>.title/description`) so translations stay in sync.
// Server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in
// Accounting) when we PUT the selection, so we only send the raw ticks.
const USAGE_GROUPS: { id: string; titleKey: string; features: FeatureKey[] }[] = [
{ id: 'crm', titleKey: 'setup.usageGroupCrm', features: ['quotes', 'contracts', 'bills', 'hoursLogging', 'customerPortal', 'calendar'] },
{ id: 'accounting', titleKey: 'setup.usageGroupAccounting', features: ['taxReport', 'incomingInvoices', 'expenses'] },
{ id: 'automation', titleKey: 'setup.usageGroupAutomation', features: ['reminderEmails', 'slideshow', 'workflows', 'whatsapp', 'incomingMail'] },
];
const ALL_USAGE_FEATURES: FeatureKey[] = USAGE_GROUPS.flatMap((g) => g.features);
// First-run screen. Reached on a fresh instance where no admin account exists
// yet — creates the first (super_admin) account from the browser using the
// one-time setup token printed to the server logs. Once an admin exists the
@@ -37,13 +53,15 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity,
});
const [step, setStep] = useState<'token' | 'account'>('token');
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isVerifyingToken, setIsVerifyingToken] = useState(false);
const [copied, setCopied] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
if (statusLoading) {
return <Loading fullScreen />;
@@ -144,7 +162,10 @@ export const SetupPage: React.FC = () => {
};
login('', adminUser);
toast.success(t('setup.success'));
navigate('/admin/dashboard', { replace: true });
// Admin now exists and we're logged in (cookie set) — advance to the
// opt-in "How will you use PicPeak?" step rather than jumping straight to
// the dashboard. Authenticated calls (feature flags) work from here.
setStep('usage');
} catch (error: any) {
const httpStatus = error.response?.status;
const data = error.response?.data;
@@ -184,10 +205,44 @@ export const SetupPage: React.FC = () => {
}
};
const stepNumber = step === 'token' ? 1 : 2;
const toggleFeature = (key: FeatureKey) => {
setSelectedFeatures((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// Persist the feature selection, then enter the app. Saving is best-effort —
// if it fails the admin can still flip features later in Settings, so we don't
// trap them on the setup screen.
const finishSetup = async () => {
setIsSavingFeatures(true);
try {
const flags: Partial<FeatureFlags> = {};
for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key);
await featureFlagsService.update(flags);
} catch (_) {
toast.warn(t('setup.featuresSaveFailed'));
} finally {
setIsSavingFeatures(false);
// If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
if (needsConfig) setStep('config');
else navigate('/admin/dashboard', { replace: true });
}
};
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: '#fafafa' }}>
<div className="w-full max-w-md">
<div className="text-center mb-8">
{/* On a fresh instance there are no branding settings yet, so use the
@@ -202,13 +257,23 @@ export const SetupPage: React.FC = () => {
</div>
);
})()}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')}
</p>
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 2 })}
<h1 className="text-3xl font-bold" style={{ color: '#171717' }}>{t('setup.title')}</h1>
<p className="mt-2" style={{ color: '#171717', opacity: 0.7 }}>
{step === 'token'
? t('setup.tokenStepSubtitle')
: step === 'account'
? t('setup.accountStepSubtitle')
: step === 'restore'
? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: t('setup.usageSubtitle')}
</p>
{(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 3 })}
</p>
)}
</div>
<Card padding="lg">
@@ -271,7 +336,7 @@ export const SetupPage: React.FC = () => {
{t('setup.continue')}
</Button>
</form>
) : (
) : step === 'account' ? (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
@@ -348,6 +413,84 @@ export const SetupPage: React.FC = () => {
</Button>
</div>
</form>
) : step === 'usage' ? (
<div className="space-y-6">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.usageAlwaysOn')}
</p>
<button
type="button"
onClick={() => setStep('restore')}
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors"
>
<span className="block text-sm font-medium text-neutral-800">{t('setup.restoreEntry')}</span>
<span className="block text-xs text-neutral-500">{t('setup.restoreEntryHint')}</span>
</button>
{USAGE_GROUPS.map((group) => (
<div key={group.id}>
<h3 className="text-sm font-semibold text-neutral-800 mb-2">{t(group.titleKey)}</h3>
<div className="space-y-2">
{group.features.map((key) => (
<label
key={key}
className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors"
>
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
checked={selectedFeatures.has(key)}
onChange={() => toggleFeature(key)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800">
{t(`settings.features.${key}.title`)}
</span>
<span className="block text-xs text-neutral-500">
{t(`settings.features.${key}.description`)}
</span>
</span>
</label>
))}
</div>
</div>
))}
{selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && (
<p className="text-xs text-neutral-500">{t('setup.usageDepsNote')}</p>
)}
<Button
type="button"
variant="primary"
size="lg"
isLoading={isSavingFeatures}
className="w-full"
onClick={finishSetup}
>
{selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
</Button>
</div>
) : step === 'restore' ? (
<div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.restoreIntro')}</p>
<PicpeakRestoreCard />
<Button
type="button"
variant="outline"
size="lg"
onClick={() => setStep('usage')}
leftIcon={<ArrowLeft className="w-4 h-4" />}
>
{t('setup.back')}
</Button>
</div>
) : (
<SetupConfigStep
selectedFeatures={selectedFeatures}
onDone={() => navigate('/admin/dashboard', { replace: true })}
/>
)}
</Card>
</div>
+10 -6
View File
@@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
import { BackupHistory } from '../../components/admin/BackupHistory';
import { RestoreWizard } from '../../components/admin/RestoreWizard';
import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard';
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
import { api } from '../../config/api';
@@ -204,12 +205,15 @@ export const BackupManagement: React.FC = () => {
{/* Tab Content */}
<div className="mt-6">
{activeTab === 'dashboard' && (
<BackupDashboard
status={backupStatus}
config={backupConfig}
onRunBackup={() => manualBackupMutation.mutate()}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
/>
<div className="space-y-6">
<BackupDashboard
status={backupStatus}
config={backupConfig}
onRunBackup={() => manualBackupMutation.mutate()}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
/>
<PicpeakExportCard />
</div>
)}
{activeTab === 'configuration' && (