From 38b3aef63d0d4f0878eddeb93f16319ef87f2372 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:13:58 +0200 Subject: [PATCH 01/19] feat(backup): .picpeak portable export (engine-neutral logical snapshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the GUI-only backup roundtrip. Adds a self-describing ".picpeak" archive that can be downloaded from one instance and (later) re-uploaded to another via the web UI only. - picpeakExportService.createPicpeak(): dumps every table as NDJSON (tables introspected at runtime — no hardcoded list, won't rot), plus a manifest (format version, app version, DB engine, latest migration, per-table row counts + checksums, includePhotos, contains_secrets), plus files/ (business-docs + uploads always; original gallery photos only when includePhotos). NDJSON is engine-neutral so the target rebuilds schema via migrations then loads rows — enabling pg↔pg / sqlite↔sqlite and forward-only auto-migrate. - GET /admin/backup/picpeak/export?includePhotos= streams the file and sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass, admin hashes, API keys — the UI must warn). - Purely additive: no existing backup/restore path is touched. Integration test proves the archive shape, knex-table exclusion, and row-count/NDJSON consistency (85 tables on the seed schema). --- .../integration/picpeakExport.test.js | 94 ++++++++ backend/src/routes/adminBackup.js | 27 +++ backend/src/services/picpeakExportService.js | 228 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 backend/__tests__/integration/picpeakExport.test.js create mode 100644 backend/src/services/picpeakExportService.js diff --git a/backend/__tests__/integration/picpeakExport.test.js b/backend/__tests__/integration/picpeakExport.test.js new file mode 100644 index 00000000..bb443d6c --- /dev/null +++ b/backend/__tests__/integration/picpeakExport.test.js @@ -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 }); + } + }); +}); diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 7a030edc..e7904a2a 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -131,6 +131,33 @@ 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' }); + } +}); + // Get backup run details router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try { diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js new file mode 100644 index 00000000..663ca252 --- /dev/null +++ b/backend/src/services/picpeakExportService.js @@ -0,0 +1,228 @@ +'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; + } +} + +// Stream one table to /.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. +async function writeTableNdjson(table, dataDir) { + const outPath = path.join(dataDir, `${table}.ndjson`); + const out = fs.createWriteStream(outPath, { encoding: 'utf8' }); + const hash = crypto.createHash('sha256'); + let rowCount = 0; + + await new Promise((resolve, reject) => { + out.on('error', reject); + const stream = db(table).stream(); + stream.on('error', reject); + stream.on('data', (row) => { + const line = `${JSON.stringify(row)}\n`; + hash.update(line); + rowCount += 1; + if (!out.write(line)) { + stream.pause(); + out.once('drain', () => stream.resume()); + } + }); + stream.on('end', () => out.end(resolve)); + }); + + return { rowCount, 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`); + + 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(); + }); + + 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, + createPicpeak, + // exported for reuse/testing + listDataTables, + collectFiles, +}; From 422dfe1cc88ae277dc170e95b4746e507c31b23a Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:31:10 +0200 Subject: [PATCH 02/19] feat(setup): add "How will you use PicPeak?" feature-selection step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the admin account is created (and we're logged in), the wizard now shows an opt-in feature step instead of jumping straight to the dashboard. Grouped ticks (Client management / Accounting / Automation) map to the existing feature flags; galleries/analytics/userManagement stay always-on and are noted, not listed. - Selection is saved via the existing authenticated PUT /admin/feature-flags, whose server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in Accounting) — the wizard only sends raw ticks. - Labels/descriptions reuse settings.features..title/description so translations stay in sync (en + de verified for all 14 features). - Saving is best-effort: on failure the admin still enters the app and can set features later in Settings. - New en/de strings for the usage step. Option A (lean wizard): this is the feature-selection foundation; per-feature hard-required config steps + the restore-from-backup branch come next. --- frontend/src/i18n/locales/de.json | 9 +++ frontend/src/i18n/locales/en.json | 9 +++ frontend/src/pages/SetupPage.tsx | 111 ++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c231bd27..9842f66b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3467,6 +3467,15 @@ "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.", "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e5c89510..403b4f9d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3363,6 +3363,15 @@ "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.", "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 789a8db4..4cca4fa0 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -8,6 +8,7 @@ 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 { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -16,6 +17,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..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 +51,15 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage'>('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>({}); + const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); + const [isSavingFeatures, setIsSavingFeatures] = useState(false); if (statusLoading) { return ; @@ -144,7 +160,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,7 +203,33 @@ 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 = {}; + 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); + navigate('/admin/dashboard', { replace: true }); + } + }; + + const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return (
@@ -204,10 +249,14 @@ export const SetupPage: React.FC = () => { })()}

{t('setup.title')}

- {step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')} + {step === 'token' + ? t('setup.tokenStepSubtitle') + : step === 'account' + ? t('setup.accountStepSubtitle') + : t('setup.usageSubtitle')}

- {t('setup.stepOf', { current: stepNumber, total: 2 })} + {t('setup.stepOf', { current: stepNumber, total: 3 })}

@@ -271,7 +320,7 @@ export const SetupPage: React.FC = () => { {t('setup.continue')} - ) : ( + ) : step === 'account' ? (
+ ) : ( +
+

+ {t('setup.usageAlwaysOn')} +

+ + {USAGE_GROUPS.map((group) => ( +
+

{t(group.titleKey)}

+
+ {group.features.map((key) => ( + + ))} +
+
+ ))} + + {selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && ( +

{t('setup.usageDepsNote')}

+ )} + + +
)} From 2920d82186d16d43674703d09370aeecec6a0da0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:35:48 +0200 Subject: [PATCH 03/19] feat(backup): .picpeak import/restore (full override, keeps current account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receiving half of the roundtrip. picpeakImportService.importFromPicpeak(): - Validates the manifest: rejects non-picpeak files, a newer format, an engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER schema than this instance (forward-only). knex_migrations absence is tolerated (test harnesses). - Snapshots the current logged-in admin, then wipes + reloads every table from the backup NDJSON in one transaction with FK enforcement suspended (pg: session_replication_role=replica reset before commit; sqlite: defer_foreign_keys). knex_migrations is never touched, so the target's schema/migration state is preserved. - Re-injects the current account so the operator is never locked out; a backup admin colliding on email is overwritten with the current creds. - Restores files/ into storage and detects external-media references so the caller can prompt to reconfigure the mount. Roundtrip integration test proves: backup data restored, current account survives a full override (different email → added), and the email-collision case keeps the operator's password. --- .../integration/picpeakRoundtrip.test.js | 119 ++++++++++ backend/src/services/picpeakImportService.js | 219 ++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 backend/__tests__/integration/picpeakRoundtrip.test.js create mode 100644 backend/src/services/picpeakImportService.js diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js new file mode 100644 index 00000000..e31ee1d7 --- /dev/null +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -0,0 +1,119 @@ +'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 superAdminRoleId; + +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; + ({ createPicpeak } = require('../../src/services/picpeakExportService')); + ({ importFromPicpeak } = 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('backup-admin@old.example', '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('current-admin@new.example', '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(?)', ['backup-admin@old.example']).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(?)', ['current-admin@new.example']).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('shared@example.com', '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('shared@example.com', '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(?)', ['shared@example.com']); + expect(rows).toHaveLength(1); + expect(rows[0].password_hash).toBe('NEW_HASH'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js new file mode 100644 index 00000000..4b61391f --- /dev/null +++ b/backend/src/services/picpeakImportService.js @@ -0,0 +1,219 @@ +'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 } = 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); + } +} + +// 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()) await trx.raw("SET session_replication_role = 'replica'"); + 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) await trx.batchInsert(table, rows, 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'); + const tables = Object.keys(manifest.tables || {}); + + 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, +}; From 2b66f6d889f94848202faf02116efa94ade9bd12 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:37:17 +0200 Subject: [PATCH 04/19] feat(backup): upload + restore endpoint for .picpeak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/backup/picpeak/import — multipart upload of a .picpeak, streamed to a temp file (after auth, so unauthenticated requests can't push a large file to disk), then restored via picpeakImportService with currentAdminId = the logged-in operator (preserved across the override). Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to reconfigure the external-media mount. Temp upload is always unlinked. Completes the backend half of the GUI-only roundtrip (export download + import upload). Multipart is already allowed by the CSRF content-type guard. --- backend/src/routes/adminBackup.js | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index e7904a2a..d58a7751 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -158,6 +158,44 @@ router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), asy } }); +// 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 { From 66d61c87cad1ac1994d61e81c8c06739aa0cb8c5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:45:16 +0200 Subject: [PATCH 05/19] feat(backup): .picpeak download + upload-restore UI in Backup Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a self-contained "Portable backup (.picpeak)" card to the Restore tab, completing the GUI-only roundtrip: - Download: optional "include original photos" toggle + a prominent plaintext-secrets warning, streams the file via a blob download. - Restore: file picker → destructive confirmation modal ("replaces ALL data except your current account, cannot be undone") → multipart upload to /admin/backup/picpeak/import → success summary. If the backup uses external media, shows a banner to reconfigure the mount, with a docs link. Kept separate from the legacy RestoreWizard (different format/flow). en+de strings added; dark-mode variants throughout. --- .../components/admin/PicpeakBackupCard.tsx | 210 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 22 +- frontend/src/i18n/locales/en.json | 22 +- frontend/src/pages/admin/BackupManagement.tsx | 6 +- 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/PicpeakBackupCard.tsx diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx new file mode 100644 index 00000000..de8072f3 --- /dev/null +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -0,0 +1,210 @@ +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'; + +interface RestoreResult { + tables: number; + filesRestored: number; + usesExternalMedia: boolean; +} + +// Portable ".picpeak" roundtrip: download a self-contained backup here, upload +// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data +// replaced) that keeps only the current account — hence the explicit confirm. +export const PicpeakBackupCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [includePhotos, setIncludePhotos] = useState(false); + const [downloading, setDownloading] = useState(false); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + + 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); + } + }; + + const onFilePick = (e: React.ChangeEvent) => { + 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('/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 ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ + {/* Download */} +
+ +
+ +

+ {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.')} +

+
+ +
+ +
+ + {/* Restore */} +
+

+ {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} +

+

+ {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} +

+ + + + {result && ( +
+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + + +

+ )} + +
+
+
+ )} +
+ + {/* Destructive confirmation */} + {pendingFile && ( +
+
+
+ +
+

+ {t('backup.picpeak.confirmTitle', 'Restore will delete all current data')} +

+

+ {t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')} +

+

{pendingFile.name}

+
+
+
+ + +
+
+
+ )} +
+ ); +}; + +PicpeakBackupCard.displayName = 'PicpeakBackupCard'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9842f66b..326f8ac1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1779,7 +1779,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" diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 403b4f9d..84ce2a13 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index 1e6532e8..d0d74586 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -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 { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -225,7 +226,10 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( - setActiveTab('integrity')} /> +
+ + setActiveTab('integrity')} /> +
)} {activeTab === 'integrity' && ( From f57462f7984c64356a9f8acb63c7df3f93909e6e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:13:18 +0200 Subject: [PATCH 06/19] fix(backup): make .picpeak roundtrip work on Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests passed because neither reproduces on SQLite): - Export: knex `.stream()` pulls in the optional `pg-query-stream` module (not bundled) and throws on pg. Switched to a plain per-table `select` — works on both engines, no new dependency. Rows are DB metadata (blobs live under files/), so holding a table in memory is fine. - Import: the pg driver returns json/jsonb columns as parsed JS values, so re-inserting a scalar like the string "PicPeak" sent it unquoted and pg rejected it ("invalid input syntax for type json"). Now introspects each table's json/jsonb columns and re-serialises those values before insert (pg only; SQLite stores json as TEXT and round-trips as-is). Verified end-to-end on docker Postgres: export 85 tables, full-override import, current account preserved, post-backup data removed. --- backend/src/services/picpeakExportService.js | 34 ++++++++------------ backend/src/services/picpeakImportService.js | 28 +++++++++++++++- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js index 663ca252..0fb8f469 100644 --- a/backend/src/services/picpeakExportService.js +++ b/backend/src/services/picpeakExportService.js @@ -76,32 +76,26 @@ async function getLatestMigration() { } } -// Stream one table to /
.ndjson (one JSON object per line). +// Write one table to /
.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 out = fs.createWriteStream(outPath, { encoding: 'utf8' }); const hash = crypto.createHash('sha256'); - let rowCount = 0; - - await new Promise((resolve, reject) => { - out.on('error', reject); - const stream = db(table).stream(); - stream.on('error', reject); - stream.on('data', (row) => { - const line = `${JSON.stringify(row)}\n`; - hash.update(line); - rowCount += 1; - if (!out.write(line)) { - stream.pause(); - out.once('drain', () => stream.resume()); - } - }); - stream.on('end', () => out.end(resolve)); + const rows = await db(table).select('*'); + const lines = rows.map((row) => { + const line = JSON.stringify(row); + hash.update(`${line}\n`); + return line; }); - - return { rowCount, checksum: hash.digest('hex') }; + 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 diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index 4b61391f..ae37455d 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -99,6 +99,30 @@ async function reinjectCurrentAdmin(trx, currentAdmin) { } } +// 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 @@ -113,7 +137,9 @@ async function replaceAllTables(tables, dataDir, currentAdmin) { } for (const table of tables) { const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`)); - if (rows.length) await trx.batchInsert(table, rows, 100); + if (!rows.length) continue; + const jsonCols = await jsonColumnsFor(trx, table); + await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100); } await reinjectCurrentAdmin(trx, currentAdmin); From cc79b3d9ec6ff412f69ab04d24af77f9285e738d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:37:35 +0200 Subject: [PATCH 07/19] refactor(backup): move .picpeak download to the Dashboard tab Downloading a portable backup is a "make a backup" action, so it belongs next to "Run Backup Now" on the Dashboard, not under Restore. Split the combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard (Restore). The manifest stays bundled inside the .picpeak, so there is no separate manifest-only download for the portable format. --- .../components/admin/PicpeakBackupCard.tsx | 199 +++++++++--------- frontend/src/pages/admin/BackupManagement.tsx | 19 +- 2 files changed, 115 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx index de8072f3..10ae1e60 100644 --- a/frontend/src/components/admin/PicpeakBackupCard.tsx +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -6,23 +6,23 @@ 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; } -// Portable ".picpeak" roundtrip: download a self-contained backup here, upload -// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data -// replaced) that keeps only the current account — hence the explicit confirm. -export const PicpeakBackupCard: React.FC = () => { +// ── Download half (Dashboard) ──────────────────────────────────────────────── +export const PicpeakExportCard: React.FC = () => { const { t } = useTranslation(); - const fileRef = useRef(null); const [includePhotos, setIncludePhotos] = useState(false); const [downloading, setDownloading] = useState(false); - const [pendingFile, setPendingFile] = useState(null); - const [restoring, setRestoring] = useState(false); - const [result, setResult] = useState(null); const handleDownload = async () => { setDownloading(true); @@ -49,6 +49,55 @@ export const PicpeakBackupCard: React.FC = () => { } }; + return ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ +
+ +
+ +

+ {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.')} +

+
+ +
+
+ ); +}; + +PicpeakExportCard.displayName = 'PicpeakExportCard'; + +// ── Restore half (Restore tab) ─────────────────────────────────────────────── +export const PicpeakRestoreCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + const onFilePick = (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (f) setPendingFile(f); @@ -77,99 +126,59 @@ export const PicpeakBackupCard: React.FC = () => { return (

- {t('backup.picpeak.title', 'Portable backup (.picpeak)')} + {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}

- {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} + {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}

+ + - {/* Download */} -
- -
- -

- {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.')} -

-
- -
- -
- - {/* Restore */} -
-

- {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} -

-

- {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} -

- - - - {result && ( -
-
- -
-

- {t('backup.picpeak.restoreDone', 'Backup restored.')} + {result && ( +

+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + +

-

- {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { - tables: result.tables, - files: result.filesRestored, - })} -

- {result.usesExternalMedia && ( -

- - - {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} - - {t('backup.picpeak.externalMediaLink', 'Setup guide')} - - - -

- )} - -
+ )} +
- )} -
+
+ )} {/* Destructive confirmation */} {pendingFile && ( @@ -207,4 +216,4 @@ export const PicpeakBackupCard: React.FC = () => { ); }; -PicpeakBackupCard.displayName = 'PicpeakBackupCard'; +PicpeakRestoreCard.displayName = 'PicpeakRestoreCard'; diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index d0d74586..b72d3acd 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,7 +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 { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard'; +import { PicpeakExportCard, PicpeakRestoreCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -205,12 +205,15 @@ export const BackupManagement: React.FC = () => { {/* Tab Content */}
{activeTab === 'dashboard' && ( - manualBackupMutation.mutate()} - isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} - /> +
+ manualBackupMutation.mutate()} + isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} + /> + +
)} {activeTab === 'configuration' && ( @@ -227,7 +230,7 @@ export const BackupManagement: React.FC = () => { {activeTab === 'restore' && (
- + setActiveTab('integrity')} />
)} From d4b143f313d00f2a30abcbc1a880c140d7455e7b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:29:47 +0200 Subject: [PATCH 08/19] fix(setup): keep the first-run wizard light regardless of dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup page background used var(--color-background), which flips to #0a0a0a under the .dark class while the wizard card stays hardcoded light — giving a dark page + light card mismatch in dark mode. Pin the first-run screen to its intended light branded look (fixed #fafafa bg / #171717 text) so all three steps render consistently. --- frontend/src/pages/SetupPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 4cca4fa0..c2747f0a 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -232,7 +232,7 @@ export const SetupPage: React.FC = () => { const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return ( -
+
{/* On a fresh instance there are no branding settings yet, so use the @@ -247,15 +247,15 @@ export const SetupPage: React.FC = () => {
); })()} -

{t('setup.title')}

-

+

{t('setup.title')}

+

{step === 'token' ? t('setup.tokenStepSubtitle') : step === 'account' ? t('setup.accountStepSubtitle') : t('setup.usageSubtitle')}

-

+

{t('setup.stepOf', { current: stepNumber, total: 3 })}

From 86324e7da75069e61686b1b77495f02c33b12e1a Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:38:48 +0200 Subject: [PATCH 09/19] feat(backup): fold .picpeak restore into the Restore wizard's Upload source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the redundant standalone .picpeak card. The wizard's 'Upload Backup' source now splits into two kinds: '.picpeak backup' (the working portable restore — renders the upload + destructive-confirm flow inline) and 'Manifest + files' (legacy, still 'Manifest Upload functionality coming soon'). en + de strings added. --- .../src/components/admin/RestoreWizard.jsx | 46 +++++++++++++++++-- frontend/src/i18n/locales/de.json | 11 ++++- frontend/src/i18n/locales/en.json | 11 ++++- frontend/src/pages/admin/BackupManagement.tsx | 7 +-- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/admin/RestoreWizard.jsx b/frontend/src/components/admin/RestoreWizard.jsx index 74f6b047..87c72296 100644 --- a/frontend/src/components/admin/RestoreWizard.jsx +++ b/frontend/src/components/admin/RestoreWizard.jsx @@ -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' && ( - -
- -

{t('backup.restore.source.upload.comingSoon')}

+
+ {/* Two upload kinds: the working portable .picpeak restore, and the + legacy manifest+files upload (still a stub). */} +
+ +
- + + {restoreData.uploadType === 'picpeak' && } + + {restoreData.uploadType === 'manifest' && ( + +
+ +

{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}

+
+
+ )} +
)}
); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 326f8ac1..befaace4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 84ce2a13..13d57bd0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3022,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", diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index b72d3acd..43b4a79c 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,7 +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, PicpeakRestoreCard } from '../../components/admin/PicpeakBackupCard'; +import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -229,10 +229,7 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( -
- - setActiveTab('integrity')} /> -
+ setActiveTab('integrity')} /> )} {activeTab === 'integrity' && ( From a95ee473ae7fb2bf3c94c610724476701145d113 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:57:50 +0200 Subject: [PATCH 10/19] feat(setup): add restore-from-backup branch to the first-run wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage step now offers 'Migrating from another PicPeak?' → a restore step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another instance onto this fresh one, preserving the account just created. en + de strings added. --- frontend/src/i18n/locales/de.json | 4 ++++ frontend/src/i18n/locales/en.json | 4 ++++ frontend/src/pages/SetupPage.tsx | 40 ++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index befaace4..f704d13e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3505,6 +3505,10 @@ "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.", "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 13d57bd0..d2e437e5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3401,6 +3401,10 @@ "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.", "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index c2747f0a..48fa2f0a 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -9,6 +9,7 @@ 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 { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -51,7 +52,7 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account' | 'usage'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -253,11 +254,15 @@ export const SetupPage: React.FC = () => { ? t('setup.tokenStepSubtitle') : step === 'account' ? t('setup.accountStepSubtitle') - : t('setup.usageSubtitle')} -

-

- {t('setup.stepOf', { current: stepNumber, total: 3 })} + : step === 'restore' + ? t('setup.restoreStepSubtitle') + : t('setup.usageSubtitle')}

+ {step !== 'restore' && ( +

+ {t('setup.stepOf', { current: stepNumber, total: 3 })} +

+ )}
@@ -397,12 +402,21 @@ export const SetupPage: React.FC = () => {
- ) : ( + ) : step === 'usage' ? (

{t('setup.usageAlwaysOn')}

+ + {USAGE_GROUPS.map((group) => (

{t(group.titleKey)}

@@ -447,6 +461,20 @@ export const SetupPage: React.FC = () => { {selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
+ ) : ( +
+

{t('setup.restoreIntro')}

+ + +
)}
From 07b450a954a53781d23a71749552e4101c637777 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:10:54 +0200 Subject: [PATCH 11/19] feat(setup): per-feature config step after feature selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the chosen features need config the wizard can collect, 'Finish' on the usage step now advances to a lean config step instead of jumping to the dashboard: - Invoicing (if Invoices): company/legal name, address, VAT-ID or tax number, IBAN, currency → saved to business-profile + a default bank account. Carries the bank/VAT legal disclaimer. - Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/ user/pass/from → saved to email_configs. Each section persists only if started, and 'Skip for now' is always available — soft settings keep their seeded defaults. en + de strings. --- .../src/components/admin/SetupConfigStep.tsx | 153 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 26 +++ frontend/src/i18n/locales/en.json | 26 +++ frontend/src/pages/SetupPage.tsx | 26 ++- 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/admin/SetupConfigStep.tsx diff --git a/frontend/src/components/admin/SetupConfigStep.tsx b/frontend/src/components/admin/SetupConfigStep.tsx new file mode 100644 index 00000000..b6a3a200 --- /dev/null +++ b/frontend/src/components/admin/SetupConfigStep.tsx @@ -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; + 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 = ({ 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) => + setInv((p) => ({ ...p, [k]: e.target.value })); + const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent) => + 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 ( +
+

+ {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.')} +

+ + {showInvoicing && ( +
+

{t('setup.config.invoicing', 'Invoicing details')}

+
+ +

+ {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.')} +

+
+ + +
+ +
+
+
+ + +
+
+ + +
+ +
+ )} + + {showEmail && ( +
+

{t('setup.config.email', 'Email delivery (SMTP)')}

+

{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}

+
+
+ +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+
+ ); +}; + +SetupConfigStep.displayName = 'SetupConfigStep'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f704d13e..3b761a57 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3509,6 +3509,32 @@ "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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d2e437e5..3ba9cb3c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3405,6 +3405,32 @@ "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", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 48fa2f0a..790c01e7 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -10,6 +10,7 @@ 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'; @@ -52,7 +53,7 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore'>('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); @@ -226,7 +227,15 @@ export const SetupPage: React.FC = () => { toast.warn(t('setup.featuresSaveFailed')); } finally { setIsSavingFeatures(false); - navigate('/admin/dashboard', { replace: true }); + // 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 }); } }; @@ -256,9 +265,11 @@ export const SetupPage: React.FC = () => { ? t('setup.accountStepSubtitle') : step === 'restore' ? t('setup.restoreStepSubtitle') - : t('setup.usageSubtitle')} + : step === 'config' + ? t('setup.config.subtitle') + : t('setup.usageSubtitle')}

- {step !== 'restore' && ( + {(step === 'token' || step === 'account' || step === 'usage') && (

{t('setup.stepOf', { current: stepNumber, total: 3 })}

@@ -461,7 +472,7 @@ export const SetupPage: React.FC = () => { {selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
- ) : ( + ) : step === 'restore' ? (

{t('setup.restoreIntro')}

@@ -475,6 +486,11 @@ export const SetupPage: React.FC = () => { {t('setup.back')}
+ ) : ( + navigate('/admin/dashboard', { replace: true })} + /> )}
From fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:39:13 +0200 Subject: [PATCH 12/19] =?UTF-8?q?fix(backup):=20address=20.picpeak=20revie?= =?UTF-8?q?w=20=E2=80=94=20table=20filter,=20superuser=20guard,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the-luap's review: - Import no longer trusts manifest.tables blindly. It now intersects the manifest's table list with the real data tables of THIS database (listDataTables(), which already excludes knex_migrations/_lock) and drops anything else. A crafted/corrupted .picpeak listing knex_migrations or a non-existent table can no longer wipe it; skipped tables are logged. - The Postgres session_replication_role='replica' SET (needs superuser) is now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are deleted (transaction rolls back) and surfaces a clear, actionable 400 instead of a cryptic permission error. - Export: on an archiver error, the temp out dir (a partial plaintext-secret archive) is now removed instead of orphaned. Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused, non-picpeak rejection, and files/ restored + filesRestored asserted. --- .../integration/picpeakRoundtrip.test.js | 63 ++++++++++++++++++- backend/src/services/picpeakExportService.js | 43 ++++++++----- backend/src/services/picpeakImportService.js | 34 ++++++++-- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js index e31ee1d7..8e26de07 100644 --- a/backend/__tests__/integration/picpeakRoundtrip.test.js +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -18,13 +18,14 @@ 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 } = require('../../src/services/picpeakImportService')); + ({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService')); const role = await db('roles').where({ name: 'super_admin' }).first(); superAdminRoleId = role.id; }, 60000); @@ -116,4 +117,64 @@ describe('.picpeak roundtrip (export → import)', () => { 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('files@example.com', '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); + }); }); diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js index 0fb8f469..561f6f54 100644 --- a/backend/src/services/picpeakExportService.js +++ b/backend/src/services/picpeakExportService.js @@ -184,23 +184,31 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { const stamp = manifest.created_at.replace(/[:.]/g, '-'); const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`); - 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(); - }); + 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})` @@ -215,6 +223,7 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) { module.exports = { PICPEAK_FORMAT_VERSION, + EXCLUDED_TABLES, createPicpeak, // exported for reuse/testing listDataTables, diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index ae37455d..b1a6b0f3 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -23,7 +23,7 @@ const knexConfig = require('../../knexfile'); const { getStoragePath } = require('../config/storage'); const { hasColumnCached } = require('../utils/schemaCache'); const logger = require('../utils/logger'); -const { PICPEAK_FORMAT_VERSION } = require('./picpeakExportService'); +const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService'); const isPostgres = () => knexConfig.client === 'pg'; @@ -129,8 +129,23 @@ function serialiseJsonColumns(rows, jsonCols) { // 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()) await trx.raw("SET session_replication_role = 'replica'"); - else await trx.raw('PRAGMA defer_foreign_keys = ON'); + 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 instance’s 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(); @@ -223,7 +238,18 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) { } const dataDir = path.join(staging, 'data'); - const tables = Object.keys(manifest.tables || {}); + // 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); From 8c86518aadae000f8e948b0cd6470730db549c1b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 08:57:48 +0200 Subject: [PATCH 13/19] fix(events): NaN from slideshow seed breaks event creation on PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create route seeds show_interval_ms/show_transition_ms from app_settings through an inline guard that pre-checked Number.isFinite(+v) but then used parseInt(v). The two disagree for null/''/true — +null is 0 (finite) while parseInt(null) is NaN — so when the slideshow settings rows are absent (getAppSetting returns its null default), NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer columns; SQLite silently stores NULL, which is why every SQLite-based test passed while POST /api/admin/events 500'd on the PG dev stack and broke the e2e smoke suite. Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers (unit-tested against every failure-mode input). Verified end-to-end: the previously-failing minimal create now succeeds against the PG dev stack. --- .../utils/numericHelpers.clampInt.test.js | 48 +++++++++++++++++++ backend/src/routes/adminEvents.js | 9 +++- backend/src/utils/numericHelpers.js | 18 ++++++- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/utils/numericHelpers.clampInt.test.js diff --git a/backend/__tests__/utils/numericHelpers.clampInt.test.js b/backend/__tests__/utils/numericHelpers.clampInt.test.js new file mode 100644 index 00000000..905c598c --- /dev/null +++ b/backend/__tests__/utils/numericHelpers.clampInt.test.js @@ -0,0 +1,48 @@ +/** + * Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug. + * + * The event-create route seeds show_interval_ms/show_transition_ms from + * app_settings via an int-parse-and-clamp. The old inline guard + * (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself + * for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so + * NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL + * rejects NaN for integer columns ("invalid input syntax for type + * integer: NaN") while SQLite silently stores NULL — so POST + * /api/admin/events 500'd on PG whenever the slideshow settings rows + * were absent (getAppSetting returns its null default). + */ + +const { clampIntOrUndefined } = require('../../src/utils/numericHelpers'); + +describe('clampIntOrUndefined', () => { + it('returns undefined for null (the getAppSetting missing-row default)', () => { + expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined(); + }); + + it('returns undefined for undefined, empty string, and booleans', () => { + expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined(); + expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined(); + expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined(); + expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined(); + }); + + it('returns undefined for non-numeric garbage', () => { + expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined(); + expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined(); + }); + + it('never returns NaN for any of the failure-mode inputs', () => { + for (const v of [null, undefined, '', true, false, 'x', {}, []]) { + const out = clampIntOrUndefined(v, 100, 5000); + expect(Number.isNaN(out)).toBe(false); + } + }); + + it('parses and clamps valid values', () => { + expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500); + expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500); + expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000); + expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000); + expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500); + }); +}); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 4b30546d..dab14a41 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -27,6 +27,7 @@ const { validateFileType } = require('../utils/fileSecurityUtils'); const { requireEventOwnership } = require('../middleware/ownership'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const { getAppSetting } = require('../utils/appSettings'); +const { clampIntOrUndefined } = require('../utils/numericHelpers'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); const downloadZipService = require('../services/downloadZipService'); @@ -682,7 +683,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [ let slideshowSeed = {}; if (await hasColumnCached('events', 'show_interval_ms')) { try { - const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); + // parseInt-first: the previous `Number.isFinite(+v)` pre-check let + // NaN through for null/''/true (+null is 0, parseInt(null) is NaN), + // producing show_interval_ms=NaN in the INSERT — PG rejects that + // with "invalid input syntax for type integer" while SQLite + // silently stores NULL, so event creation 500'd on PG whenever the + // slideshow app_settings rows were absent. + const intP = (v, min, max) => clampIntOrUndefined(v, min, max); const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); diff --git a/backend/src/utils/numericHelpers.js b/backend/src/utils/numericHelpers.js index b0ce39ff..6099d73a 100644 --- a/backend/src/utils/numericHelpers.js +++ b/backend/src/utils/numericHelpers.js @@ -31,4 +31,20 @@ function ensureNumber(value, fallback = 0) { return Number.isFinite(n) ? n : fallback; } -module.exports = { ensureInt, ensureNumber }; +/** + * Parse a value as an integer clamped to [min, max]; `undefined` on + * anything that doesn't parse (null, undefined, '', booleans, garbage). + * + * Exists because the inline guard `Number.isFinite(+v) ? parseInt(v)` + * disagrees with itself for null/''/true (`+null` is 0 but + * `parseInt(null)` is NaN), which let NaN through Math.min/Math.max + * and into an INSERT — PostgreSQL rejects NaN for integer columns + * while SQLite silently stores NULL, so it only failed on PG. + */ +function clampIntOrUndefined(value, min, max) { + const n = parseInt(value, 10); + if (!Number.isFinite(n)) return undefined; + return Math.min(max, Math.max(min, n)); +} + +module.exports = { ensureInt, ensureNumber, clampIntOrUndefined }; From 760c3d7b67963105f155a43297f165b7e672b074 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 08:57:48 +0200 Subject: [PATCH 14/19] fix(admin): stray literal "0" rendered from SQLite integer booleans On SQLite deployments boolean event columns come back as 0/1, and {event.is_draft && ...} renders the 0 as a literal text node. Visible on the event details page in three spots: above the tab bar (is_draft), in the download-protection badge row (disable_right_click / enable_devtools_protection / watermark_downloads), and in the Client Access card (client_access_enabled). Coerce with !! at the render sites. --- frontend/src/pages/admin/EventDetailsPage.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 3c5e4e4f..c88750f6 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -1053,7 +1053,8 @@ export const EventDetailsPage: React.FC = () => {
{/* Draft Banner */} - {event.is_draft && !event.is_archived && ( + {/* !! — SQLite returns integer booleans; a bare 0 would render as literal "0" */} + {!!event.is_draft && !event.is_archived && (
@@ -1910,13 +1911,14 @@ export const EventDetailsPage: React.FC = () => { }`}> {event.protection_level || 'standard'} - {event.disable_right_click && ( + {/* !! on the next three — SQLite integer booleans render literal "0" when falsy */} + {!!event.disable_right_click && ( {t('events.rightClickBlocked', 'Right-click blocked')} )} - {event.enable_devtools_protection && ( + {!!event.enable_devtools_protection && ( {t('events.devtoolsDetection', 'DevTools detection')} @@ -1928,7 +1930,7 @@ export const EventDetailsPage: React.FC = () => { {t('events.downloadsDisabled', 'Downloads disabled')} )} - {event.watermark_downloads && ( + {!!event.watermark_downloads && ( {t('events.watermarked', 'Watermarked')} @@ -2068,7 +2070,8 @@ export const EventDetailsPage: React.FC = () => {
- {event?.client_access_enabled && ( + {/* !! — SQLite integer boolean; bare 0 renders as literal "0" */} + {!!event?.client_access_enabled && ( <> {/* Set/Change PIN */}
From 5b26dbd93553bfa7fe6815407d97edd74599c371 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:05:34 +0200 Subject: [PATCH 15/19] chore(main): release 3.80.0-beta.0 (#736) --- .release-please-manifest-beta.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ backend/package.json | 2 +- frontend/package.json | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index a89a392e..43cb284a 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.79.1-beta.0" + ".": "3.80.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2e303c..e994da5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03) + + +### Features + +* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a)) +* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23)) +* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113)) +* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777)) + + +### Bug Fixes + +* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2)) +* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b)) + ## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02) diff --git a/backend/package.json b/backend/package.json index f9286915..3d97a11e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.79.1-beta.0", + "version": "3.80.0-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/frontend/package.json b/frontend/package.json index 39012c25..1f11d6f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.79.1-beta.0", + "version": "3.80.0-beta.0", "type": "module", "scripts": { "dev": "vite", From 081f3edcdffc65a77000cc638e364ea9dc03767f Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 10:27:28 +0200 Subject: [PATCH 16/19] fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth/access-control audit fixes (all pre-existing on main; none are regressions). Verified end-to-end where noted. HIGH - Thumbnail enumeration: photoAuth granted any gallery token access to any flat /thumbnails/thumb_* file, so a visitor to one gallery could enumerate another (password-protected) gallery's entire thumbnail set. Scope thumbnail access to the token's event via photos.thumbnail_path. Live-verified: cross-event fetch now 404s, own-event still 200s. - Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied event ids with no owner filter (single-event routes enforce requireEventOwnership), letting admin/editor archive or cascade-delete any event. Add filterOwnedEventIds; also guard rename + import-external; tighten photo-retry to scope admin (not just editor). Fix misleading bulk-delete comment. MED - verifyGalleryAccess never checked decoded.type — assert 'gallery' instead of relying on other token types incidentally lacking eventId. - secure-images generate-token/secure-download missing denySlideshowToken (#646 bypass): a leaked slideshow token could download originals. - Frontend: AuthenticatedImage + api.ts attached the gallery bearer token to absolute/external URLs — only attach to relative same-app paths. LOW hardening - Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls. - crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe). - Remove dead photoAuth import in galleryFeedback. Tests: new regression suites for thumbnail scoping + filterOwnedEventIds; fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens carry type:'gallery'). Full backend suite at the pre-existing baseline (5 suites/27 tests fail on main too), zero new failures. --- .../ownership.filterOwnedEventIds.test.js | 78 +++++++++++++ .../photoAuth.thumbnailScope.test.js | 103 ++++++++++++++++++ ...verifyGalleryAccess.customerRevoke.test.js | 4 + backend/src/middleware/auth.js | 4 +- backend/src/middleware/customerAuth.js | 1 + backend/src/middleware/gallery.js | 15 ++- backend/src/middleware/guestAuth.js | 1 + backend/src/middleware/ownership.js | 34 +++++- backend/src/middleware/photoAuth.js | 37 +++++-- backend/src/middleware/sessionTimeout.js | 4 +- backend/src/routes/adminEventRename.js | 5 +- backend/src/routes/adminEvents.js | 55 +++++++--- backend/src/routes/adminExternalMedia.js | 3 +- backend/src/routes/adminPhotos.js | 14 ++- backend/src/routes/auth.js | 3 +- backend/src/routes/galleryFeedback.js | 1 - backend/src/routes/protectedImages.js | 5 +- backend/src/routes/secureImages.js | 5 +- backend/src/utils/rateLimitSecurity.js | 2 +- backend/src/utils/timingSafe.js | 22 ++++ .../components/common/AuthenticatedImage.tsx | 20 +++- frontend/src/config/api.ts | 16 ++- 22 files changed, 372 insertions(+), 60 deletions(-) create mode 100644 backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js create mode 100644 backend/__tests__/middleware/photoAuth.thumbnailScope.test.js create mode 100644 backend/src/utils/timingSafe.js diff --git a/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js new file mode 100644 index 00000000..066080c0 --- /dev/null +++ b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js @@ -0,0 +1,78 @@ +/** + * Regression test for the bulk archive/delete ownership bypass. + * + * bulk-archive and bulk-delete acted on body-supplied event ids with no + * ownership filter, so an admin/editor scoped to their own events (the + * single-event routes enforce requireEventOwnership) could archive or + * cascade-delete ANY event by id. filterOwnedEventIds is the helper those + * routes now use to drop foreign/non-existent ids. + */ + +// events owned by admin 7; event 3 owned by someone else; event 4 is +// ownerless (legacy). The mock models: +// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id) +const EVENTS = [ + { id: 1, created_by: 7 }, + { id: 2, created_by: 7 }, + { id: 3, created_by: 99 }, // foreign + { id: 4, created_by: null }, // ownerless/legacy +]; + +jest.mock('../../src/database/db', () => ({ + db: () => { + const q = { + _ids: null, + _adminId: null, + whereIn(_col, ids) { this._ids = ids; return this; }, + andWhere(cb) { + // Emulate the (created_by IS NULL OR created_by = admin.id) builder + // by capturing the admin id the callback closes over via a probe. + const probe = { + _adminId: null, + whereNull() { return this; }, + orWhere(_col, id) { this._adminId = id; return this; }, + }; + cb(probe); + this._adminId = probe._adminId; + return this; + }, + select() { + return Promise.resolve( + EVENTS + .filter((e) => this._ids.includes(e.id)) + .filter((e) => e.created_by === null || e.created_by === this._adminId) + .map((e) => ({ id: e.id })) + ); + }, + }; + return q; + }, +})); + +const { filterOwnedEventIds } = require('../../src/middleware/ownership'); + +describe('filterOwnedEventIds', () => { + it('super_admin gets every id, nothing denied', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'super_admin' }, [1, 3, 4, 999] + ); + expect(allowed).toEqual([1, 3, 4, 999]); + expect(denied).toEqual([]); + }); + + it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999] + ); + expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless + expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing + }); + + it('foreign-only request yields empty allowed', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'editor' }, [3] + ); + expect(allowed).toEqual([]); + expect(denied).toEqual([3]); + }); +}); diff --git a/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js new file mode 100644 index 00000000..df4050f7 --- /dev/null +++ b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js @@ -0,0 +1,103 @@ +/** + * Regression test for the cross-event thumbnail enumeration leak. + * + * Thumbnails are served flat from /thumbnails/thumb_ with + * deterministic, enumerable filenames. photoAuth previously granted any + * holder of a gallery token for ANY active event access to ANY thumbnail + * (it set eventSlug=null and returned next() as long as the token's event + * existed), so a visitor to one gallery could pull another (password- + * protected) gallery's entire thumbnail set. The fix scopes thumbnail + * access to the token's event by matching the requested file against + * photos.thumbnail_path for that event_id. + */ + +process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000'; + +const jwt = require('jsonwebtoken'); + +// Two events, each owning one thumbnail. The photos mock resolves a row +// only when BOTH event_id and thumbnail_path match — i.e. it models the +// real ownership query. +const EVENTS = [ + { id: 10, slug: 'event-a', is_active: 1 }, + { id: 20, slug: 'event-b', is_active: 1 }, +]; +const PHOTOS = [ + { id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' }, + { id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' }, +]; + +jest.mock('../../src/database/db', () => ({ + db: (table) => ({ + _cond: null, + where(cond) { this._cond = cond; return this; }, + first() { + if (table === 'events') { + return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null); + } + if (table === 'photos') { + return Promise.resolve( + PHOTOS.find((p) => p.event_id === this._cond.event_id + && p.thumbnail_path === this._cond.thumbnail_path) || null + ); + } + return Promise.resolve(null); + }, + }), +})); + +jest.mock('../../src/utils/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); + +const photoAuth = require('../../src/middleware/photoAuth'); + +function galleryToken(eventId) { + return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' }); +} + +function makeReqRes(token, thumbPath) { + const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} }; + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; }, + }; + return { req, res }; +} + +describe('photoAuth — thumbnail ownership scoping', () => { + it('denies a gallery token for event A fetching event B\'s thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + // Access denied: middleware must not pass the request through. + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(req.event).toBeUndefined(); + }); + + it('allows a gallery token to fetch its own event\'s thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.event).toMatchObject({ id: 20 }); + }); + + it('denies a traversal / foreign filename that matches no owned thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(req.event).toBeUndefined(); + }); +}); diff --git a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js index 1ebbefef..55302bbb 100644 --- a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js +++ b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js @@ -101,6 +101,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', ( it('allows access when the event_customer_assignments row exists', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, via: 'customer', customerId: 7, @@ -131,6 +132,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, via: 'customer', customerId: 7, @@ -160,6 +162,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { // and start 403'ing per-event-password sessions. getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, customerId: 7, // intentionally no `via` claim @@ -191,6 +194,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => { it('does NOT touch event_customer_assignments and passes through', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, // No via, no customerId — this is the legacy per-event-password // flow where every guest mints their own JWT after entering the diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 214485f1..ccb49903 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -18,6 +18,7 @@ async function adminAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true }); @@ -140,6 +141,7 @@ async function galleryAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true }); @@ -209,7 +211,7 @@ async function photoAuth(req, res, next) { let decoded; try { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } catch (err) { return res.status(401).json({ error: 'Invalid token' }); } diff --git a/backend/src/middleware/customerAuth.js b/backend/src/middleware/customerAuth.js index 1d2e717b..e1b9c545 100644 --- a/backend/src/middleware/customerAuth.js +++ b/backend/src/middleware/customerAuth.js @@ -34,6 +34,7 @@ async function customerAuth(req, res, next) { let decoded; try { const verified = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true, }); diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 8d4ccf56..a1ee968a 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -66,18 +66,29 @@ async function verifyGalleryAccess(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth' }); } catch (error) { // If verification fails with issuer, try without issuer (backward compatibility) if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } else { throw error; } } logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug }); - + + // Only gallery-scoped tokens grant gallery access. Every legitimate + // path (password login, share link, client access, customer-minted, + // slideshow) mints type:'gallery'. Reject anything else — e.g. a guest + // identity token (type:'guest', for feedback attribution) that carries a + // matching eventId — instead of relying on other token types incidentally + // lacking an eventId to fail the id match below. + if (decoded.type !== 'gallery') { + return res.status(403).json({ error: 'Invalid token type for gallery access' }); + } + // If we have a slug in the URL params or from pre-middleware, verify it matches if (requestedSlug) { // Verify by slug and ensure it matches the token's event diff --git a/backend/src/middleware/guestAuth.js b/backend/src/middleware/guestAuth.js index f50e9d1d..a21eddb5 100644 --- a/backend/src/middleware/guestAuth.js +++ b/backend/src/middleware/guestAuth.js @@ -23,6 +23,7 @@ async function resolveGuest(req, res, next) { let decoded; try { const verified = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true, }); diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 7ebad2ff..322c0aad 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -32,4 +32,36 @@ function requireEventOwnership(req, res, next) { }); } -module.exports = { requireEventOwnership }; +/** + * Return the subset of `eventIds` the admin may act on, mirroring + * requireEventOwnership for bulk routes that can't use it (they take an + * array in the body, not an :id param). super_admin gets everything; + * other roles get events they created plus ownerless legacy/system + * events (created_by IS NULL). Ids that are foreign OR non-existent both + * land in `denied` — deliberately indistinguishable, so bulk routes + * don't become an ownership/existence oracle. + * + * @returns {Promise<{allowed: Array, denied: Array}>} + */ +async function filterOwnedEventIds(admin, eventIds) { + if (admin.roleName === 'super_admin') { + return { allowed: [...eventIds], denied: [] }; + } + const rows = await db('events') + .whereIn('id', eventIds) + .andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id)) + .select('id'); + const allowedSet = new Set(rows.map((r) => r.id)); + const allowed = []; + const denied = []; + for (const id of eventIds) { + if (allowedSet.has(id) || allowedSet.has(Number(id))) { + allowed.push(id); + } else { + denied.push(id); + } + } + return { allowed, denied }; +} + +module.exports = { requireEventOwnership, filterOwnedEventIds }; diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 6f832aa1..df7a1433 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -28,12 +28,13 @@ async function photoAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth' }); } catch (issuerError) { // If verification fails with issuer, try without issuer (backward compatibility) if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } else { throw issuerError; } @@ -43,24 +44,36 @@ async function photoAuth(req, res, next) { if (decoded.type === 'gallery') { // For thumbnails, we need to verify the token is for a valid event if (!eventSlug) { - // Extract event ID from the decoded token + // Resolve the token's event (by id, or legacy slug fallback)... + let event = null; if (decoded.eventId) { - const event = await db('events') + event = await db('events') .where({ id: decoded.eventId, is_active: formatBoolean(true) }) .first(); - if (event) { + } + if (!event && decoded.eventSlug) { + event = await db('events') + .where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }) + .first(); + } + // ...then confirm the REQUESTED thumbnail actually belongs to + // that event. Thumbnails are stored flat (thumbnails/thumb_) + // with deterministic, enumerable filenames derived from the + // public event name + a sequential counter. Without this + // ownership check any holder of a gallery token for any event + // could enumerate and fetch another (password-protected) event's + // entire thumbnail set, defeating the gallery password. A + // traversal or foreign filename simply fails to match → denied. + if (event) { + const requestedKey = `thumbnails${req.path}`; + const ownsThumbnail = await db('photos') + .where({ event_id: event.id, thumbnail_path: requestedKey }) + .first(); + if (ownsThumbnail) { req.event = event; return next(); } } - // Fallback to slug - const event = await db('events') - .where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }) - .first(); - if (event) { - req.event = event; - return next(); - } } // For regular photos, check if token matches the event else if (decoded.eventSlug === eventSlug) { diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index 3c7968bb..88ef5dc3 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -86,7 +86,7 @@ async function sessionTimeoutMiddleware(req, res, next) { try { // Verify token is valid - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // Check if this is an admin token if (!decoded.id) { @@ -127,7 +127,7 @@ async function sessionTimeoutMiddleware(req, res, next) { for (const [oldToken, _] of sessions.entries()) { if (oldToken !== token) { try { - const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET); + const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); if (oldDecoded.id === userId) { sessions.delete(oldToken); } diff --git a/backend/src/routes/adminEventRename.js b/backend/src/routes/adminEventRename.js index 00bdbfc4..cc820568 100644 --- a/backend/src/routes/adminEventRename.js +++ b/backend/src/routes/adminEventRename.js @@ -7,6 +7,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); const eventRenameService = require('../services/eventRenameService'); const router = express.Router(); @@ -14,7 +15,7 @@ const router = express.Router(); * POST /api/admin/events/:eventId/rename * Rename an event */ -router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [ +router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) @@ -59,7 +60,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [ * POST /api/admin/events/:eventId/validate-rename * Validate a potential rename without executing it */ -router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [ +router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 4b30546d..f6040c71 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -24,7 +24,7 @@ const eventTypeService = require('../services/eventTypeService'); const { normaliseEventTimeTriple } = require('../services/eventService'); const { hasColumnCached } = require('../utils/schemaCache'); const { validateFileType } = require('../utils/fileSecurityUtils'); -const { requireEventOwnership } = require('../middleware/ownership'); +const { requireEventOwnership, filterOwnedEventIds } = require('../middleware/ownership'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const { getAppSetting } = require('../utils/appSettings'); const { getFrontendBaseUrl } = require('../utils/frontendUrl'); @@ -2260,25 +2260,39 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ } const { eventIds } = req.body; - + if (eventIds.length === 0) { return res.status(400).json({ error: 'No events selected for archiving' }); } - // Get all events to archive - const events = await db('events') - .whereIn('id', eventIds) - .where('is_archived', formatBoolean(false)); - - if (events.length === 0) { - return res.status(400).json({ error: 'No valid events found to archive' }); - } + // Ownership scope: a non-super_admin may only archive events they own. + // Foreign/non-existent ids are dropped and reported as failures so this + // route can't archive another admin's events (the single-event + // /:id/archive route enforces the same via requireEventOwnership). + const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds); const results = { successful: [], - failed: [] + failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' })) }; + // Get all events to archive + const events = allowedIds.length + ? await db('events') + .whereIn('id', allowedIds) + .where('is_archived', formatBoolean(false)) + : []; + + if (events.length === 0) { + if (results.failed.length > 0) { + return res.json({ + message: `Bulk archive completed: 0 succeeded, ${results.failed.length} failed`, + results + }); + } + return res.status(400).json({ error: 'No valid events found to archive' }); + } + // Process each event for (const event of events) { try { @@ -2353,16 +2367,21 @@ router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ const { eventIds } = req.body; - // Editor-role events.delete permission is already gated by the route - // middleware. We do NOT additionally filter to created_by here because - // the per-event delete-cascade is global (matches DELETE /:id which - // also has no role-based filter — that's why events.delete is a - // sensitive permission). + // Ownership scope: a non-super_admin may only delete events they own. + // The single-event DELETE /:id route enforces this via + // requireEventOwnership; this bulk route must match it, otherwise an + // admin/editor scoped to their own events could cascade-delete any + // event by id. Foreign/non-existent ids are dropped and reported as + // failures (indistinguishable, to avoid an existence oracle). + const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds); - const results = { successful: [], failed: [] }; + const results = { + successful: [], + failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' })) + }; const adminContext = { id: req.admin.id, username: req.admin.username }; - for (const eventId of eventIds) { + for (const eventId of allowedIds) { try { const deleted = await deleteEventCascade(eventId, adminContext); results.successful.push(deleted); diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index 8566bfff..de9e6c06 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -3,6 +3,7 @@ const path = require('path'); const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); const sharp = require('sharp'); @@ -48,7 +49,7 @@ async function walkDir(dir, baseDir) { // POST /api/admin/events/:id/import-external // Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } } -router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => { +router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => { try { const eventId = parseInt(req.params.id); const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {}; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 50496a52..56429d8d 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -601,12 +601,18 @@ router.post( const photo = await db('photos').where({ id: req.params.photoId }).first(); if (!photo) return res.status(404).json({ error: 'Photo not found' }); - // Editor role: only allow retry on photos in events they own. - if (req.admin.roleName === 'editor') { + // Ownership scope: any non-super_admin may only retry photos in events + // they own — matching requireEventOwnership (which scopes both the + // admin and editor roles; only super_admin bypasses). Previously this + // checked the editor role alone, leaving admin-role users able to + // reprocess another admin's photos. + if (req.admin.roleName !== 'super_admin') { const event = await db('events') - .where({ id: photo.event_id, created_by: req.admin.id }) + .where({ id: photo.event_id }) .first(); - if (!event) return res.status(404).json({ error: 'Photo not found' }); + if (event && event.created_by && event.created_by !== req.admin.id) { + return res.status(404).json({ error: 'Photo not found' }); + } } if (photo.processing_status !== 'failed') { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index e99b5861..3ad12e8c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -14,6 +14,7 @@ const { } = require('../utils/authSecurity'); const { endSession } = require('../middleware/sessionTimeout'); const { revokeToken } = require('../utils/tokenRevocation'); +const { timingSafeEqualStr } = require('../utils/timingSafe'); const logger = require('../utils/logger'); const { setAdminAuthCookie, @@ -413,7 +414,7 @@ router.post('/gallery/share-login', [ const expectedToken = getEventShareToken(event); - if (!expectedToken || token !== expectedToken) { + if (!expectedToken || !timingSafeEqualStr(token, expectedToken)) { await trackFailedAttempt(shareIdentifier, ipAddress, userAgent); return res.status(401).json({ error: 'Invalid or expired share link' }); } diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index db58bc8a..9ca5f00d 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -1,6 +1,5 @@ const express = require('express'); const router = express.Router(); -const { photoAuth } = require('../middleware/photoAuth'); const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery'); const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const { resolveGuest } = require('../middleware/guestAuth'); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 15ddbb1b..572ebcd1 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -8,6 +8,7 @@ const { getStorage } = require('../services/storage'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver'); const { withLocalCopy } = require('../services/imageProcessor'); const crypto = require('crypto'); +const { timingSafeEqualStr } = require('../utils/timingSafe'); const router = express.Router(); @@ -32,9 +33,9 @@ function verifyImageToken(token) { const decoded = Buffer.from(data, 'base64').toString(); const [photoId, expires] = decoded.split(':'); - // Verify signature + // Verify signature (constant-time — avoids leaking the HMAC byte-by-byte) const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex'); - if (signature !== expectedSignature) { + if (!timingSafeEqualStr(signature, expectedSignature)) { return null; } diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js index 79093eee..c4335b54 100644 --- a/backend/src/routes/secureImages.js +++ b/backend/src/routes/secureImages.js @@ -1,6 +1,6 @@ const express = require('express'); const { db } = require('../database/db'); -const { verifyGalleryAccess } = require('../middleware/gallery'); +const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery'); const secureImageService = require('../services/secureImageService'); const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const logger = require('../utils/logger'); @@ -23,7 +23,7 @@ router.post('/:slug/generate-token', async (req, res, next) => { // Add slug to request for verifyGalleryAccess req.requestedSlug = req.params.slug; next(); -}, verifyGalleryAccess, async (req, res) => { +}, verifyGalleryAccess, denySlideshowToken, async (req, res) => { try { const { photoId, accessType = 'view' } = req.body; @@ -273,6 +273,7 @@ router.get('/:slug/secure-download/:photoId/:token', next(); }, verifyGalleryAccess, + denySlideshowToken, async (req, res) => { try { const { photoId, token } = req.params; diff --git a/backend/src/utils/rateLimitSecurity.js b/backend/src/utils/rateLimitSecurity.js index d7614f6a..0ba002c4 100644 --- a/backend/src/utils/rateLimitSecurity.js +++ b/backend/src/utils/rateLimitSecurity.js @@ -32,7 +32,7 @@ function hasValidAdminToken(req) { // Critical: Verify token is valid before skipping rate limit // This prevents invalid tokens from bypassing rate limiting - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // Additional validation if (!decoded || typeof decoded !== 'object') { diff --git a/backend/src/utils/timingSafe.js b/backend/src/utils/timingSafe.js new file mode 100644 index 00000000..9fb4b2e7 --- /dev/null +++ b/backend/src/utils/timingSafe.js @@ -0,0 +1,22 @@ +const crypto = require('crypto'); + +/** + * Constant-time string comparison for secrets (share tokens, HMAC + * signatures, etc.). Returns false for non-strings or length mismatch + * without leaking timing beyond the (non-secret) length. Prevents an + * attacker from recovering a token byte-by-byte via response-time + * differences of a naive `a === b`. + */ +function timingSafeEqualStr(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') { + return false; + } + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) { + return false; + } + return crypto.timingSafeEqual(ab, bb); +} + +module.exports = { timingSafeEqualStr }; diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index 71ad2c2f..ea862703 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -137,18 +137,26 @@ export const AuthenticatedImage: React.FC = ({ throw new Error('No URL provided'); } - // Build full URL for the image + // Build full URL for the image. Only relative paths are app-owned; + // an absolute URL is passed through untouched. + const isRelative = rawUrl.startsWith('/'); const fullImageUrl = rawUrl.startsWith('/admin') ? buildResourceUrl(`/api${rawUrl}`) - : rawUrl.startsWith('/') + : isRelative ? buildResourceUrl(rawUrl) : rawUrl; const headers: Record = {}; - const slugForRequest = resolveSlug(rawUrl); - const token = getGalleryToken(slugForRequest); - if (token) { - headers.Authorization = `Bearer ${token}`; + // Attach the gallery bearer token ONLY to relative (same-app) image + // paths. Never send it to an absolute/external URL — that would leak + // gallery credentials cross-origin. AuthenticatedImage does not + // support external URLs by design. + if (isRelative) { + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } } const response = await fetch(fullImageUrl, { diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index d2e609ae..697afea4 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -56,11 +56,19 @@ api.interceptors.request.use( const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; - const isGalleryEndpoint = /^\/gallery\//.test(pathname) - || /^\/secure-images\//.test(pathname) - || /^\/auth\/gallery\//.test(pathname); + // Never attach the gallery token to an absolute URL. Requests to the + // app's own API use relative paths (axios prepends baseURL); an + // absolute URL could point at any origin, and extracting its + // `/gallery/...` pathname would otherwise match below and leak the + // bearer token cross-origin. + const isAbsoluteUrl = /^https?:\/\//i.test(config.url || ''); - const isGallerySessionCheck = pathname === '/auth/session' + const isGalleryEndpoint = !isAbsoluteUrl && ( + /^\/gallery\//.test(pathname) + || /^\/secure-images\//.test(pathname) + || /^\/auth\/gallery\//.test(pathname)); + + const isGallerySessionCheck = !isAbsoluteUrl && pathname === '/auth/session' && (!!paramSlug || window.location.pathname.startsWith('/gallery/')); if (isGalleryEndpoint || isGallerySessionCheck) { From 72e2ef6721b0572ed34455de901aa357eacd8c76 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 11:33:38 +0200 Subject: [PATCH 17/19] =?UTF-8?q?feat(auth):=20admin=20TOTP=20MFA=20?= =?UTF-8?q?=E2=80=94=20enrollment,=20login=20challenge,=20recovery,=20CLI?= =?UTF-8?q?=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend for #738. Real TOTP 2FA for admin accounts, all roles incl. super_admin (closes #735). - mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest (key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed, single-use recovery codes; otpauth URI + QR. - Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at (secret/enabled columns already existed from legacy 016). - Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status, POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate require a current code so a hijacked session can't strip 2FA. - Login challenge: /admin/login returns {mfaRequired, mfaToken} (no session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery code for the session. Lockout counter is NOT reset until the second factor passes, so MFA brute-force is rate-limited too. - CLI break-glass: scripts/reset-admin-mfa.js --email | --all --yes, audit-logged, matches reset-admin-password.js convention. - Docs + optional MFA_ENCRYPTION_KEY env. Verified end-to-end on a live backend: enroll (super_admin), challenge, TOTP + single-use recovery login, disable, and CLI reset. --- backend/.env.example | 10 + .../core/151_add_admin_mfa_recovery.js | 58 +++++ backend/package-lock.json | 74 ++++++- backend/package.json | 7 +- backend/scripts/reset-admin-mfa.js | 109 ++++++++++ backend/src/routes/adminAuth.js | 172 +++++++++++++++ backend/src/routes/auth.js | 199 ++++++++++++++---- backend/src/services/mfaService.js | 183 ++++++++++++++++ 8 files changed, 771 insertions(+), 41 deletions(-) create mode 100644 backend/migrations/core/151_add_admin_mfa_recovery.js create mode 100644 backend/scripts/reset-admin-mfa.js create mode 100644 backend/src/services/mfaService.js diff --git a/backend/.env.example b/backend/.env.example index 15ab3ddd..300858e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,16 @@ PORT=3001 # Generate with: openssl rand -base64 32 JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 +# Admin 2FA (TOTP) secret encryption key — OPTIONAL. +# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default +# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it +# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so +# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set +# it, changing/losing it makes existing 2FA secrets undecryptable — recover +# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes +# Generate with: openssl rand -base64 32 +#MFA_ENCRYPTION_KEY= + # Auth cookie Secure flag # unset - default: 'auto' in production, false in dev (#427) # true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access — diff --git a/backend/migrations/core/151_add_admin_mfa_recovery.js b/backend/migrations/core/151_add_admin_mfa_recovery.js new file mode 100644 index 00000000..fed7b651 --- /dev/null +++ b/backend/migrations/core/151_add_admin_mfa_recovery.js @@ -0,0 +1,58 @@ +/** + * Migration 151: admin MFA (TOTP) enrollment support — issue #738. + * + * The `admin_users.two_factor_enabled` / `two_factor_secret` columns already + * exist from the legacy migration 016 but were never wired to any code. This + * migration adds the two columns the real TOTP flow needs on top of them: + * + * - two_factor_recovery_codes: JSON array of one-time backup codes, stored + * HASHED (never plaintext), so a locked-out admin can log in without the + * authenticator. Consumed on use. + * - two_factor_enrolled_at: when the admin completed enrollment (audit / + * display only). + * + * The TOTP secret itself continues to live in the existing `two_factor_secret` + * column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService — + * the column type is unchanged (the encrypted blob is short). + * + * Additive and idempotent: only adds columns, guarded by hasColumn, so it is + * safe to re-run and touches no existing data. + */ +exports.up = async function (knex) { + const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes'); + const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at'); + const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled'); + const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret'); + + await knex.schema.alterTable('admin_users', (t) => { + // Backfill the legacy columns too, in case an install somehow lacks them + // (016 is a legacy migration; guard defensively). + if (!hasEnabled) { + t.boolean('two_factor_enabled').defaultTo(false); + } + if (!hasSecret) { + t.string('two_factor_secret').nullable(); + } + if (!hasRecovery) { + t.text('two_factor_recovery_codes').nullable(); + } + if (!hasEnrolledAt) { + t.timestamp('two_factor_enrolled_at').nullable(); + } + }); +}; + +exports.down = async function (knex) { + const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes'); + const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at'); + + await knex.schema.alterTable('admin_users', (t) => { + // Only drop what THIS migration added; leave the legacy 016 columns. + if (hasRecovery) { + t.dropColumn('two_factor_recovery_codes'); + } + if (hasEnrolledAt) { + t.dropColumn('two_factor_enrolled_at'); + } + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 60ecdaa0..87fbe8ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.74.0-beta.0", + "version": "3.80.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.74.0-beta.0", + "version": "3.80.0-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -40,6 +40,7 @@ "node-cron": "^3.0.2", "node-stream-zip": "^1.15.0", "nodemailer": "^9.0.1", + "otplib": "^12.0.1", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pg": "^8.16.3", @@ -2703,6 +2704,56 @@ "node": ">=10" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -9371,6 +9422,17 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11668,6 +11730,14 @@ "dev": true, "license": "MIT" }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3d97a11e..702e7cf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,9 +46,11 @@ "node-cron": "^3.0.2", "node-stream-zip": "^1.15.0", "nodemailer": "^9.0.1", + "otplib": "^12.0.1", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", "pg": "^8.16.3", + "postcss": "8.5.10", "qrcode": "^1.5.4", "react-i18next": "^15.6.0", "sanitize-html": "^2.17.0", @@ -57,11 +59,10 @@ "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "swissqrbill": "^4.3.0", + "tar": ">=7.5.16", "uuid": "^11.1.1", "winston": "^3.8.2", - "zxcvbn": "^4.4.2", - "postcss": "8.5.10", - "tar": ">=7.5.16" + "zxcvbn": "^4.4.2" }, "devDependencies": { "eslint": "^8.40.0", diff --git a/backend/scripts/reset-admin-mfa.js b/backend/scripts/reset-admin-mfa.js new file mode 100644 index 00000000..ce4985cb --- /dev/null +++ b/backend/scripts/reset-admin-mfa.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738). + * + * Break-glass recovery for when an admin loses their authenticator AND their + * recovery codes. Clears the MFA state so the admin can log in with just their + * password and re-enroll from Settings. + * + * Usage (inside the running backend container): + * docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com + * docker compose exec backend node scripts/reset-admin-mfa.js --all --yes + * + * Flags: + * --email target a single admin by email (or --username ) + * --all reset MFA for EVERY admin (full lockout / break-glass) + * --yes non-interactive (skip the confirmation prompt) + */ + +const readline = require('readline'); +const { db, logActivity } = require('../src/database/db'); + +const args = process.argv.slice(2); +const hasFlag = (f) => args.includes(f); +const getOption = (name) => { + const i = args.indexOf(`--${name}`); + return i !== -1 && i + 1 < args.length ? args[i + 1] : null; +}; + +const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive'); +const all = hasFlag('--all'); +const email = getOption('email'); +const username = getOption('username'); + +const MFA_CLEAR = { + two_factor_enabled: false, + two_factor_secret: null, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date(), +}; + +function ask(prompt) { + if (force) return Promise.resolve('yes'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); })); +} + +async function main() { + console.log('\n========================================'); + console.log('PicPeak Admin MFA Reset Tool'); + console.log('========================================\n'); + + if (!all && !email && !username) { + console.error('❌ Specify a target: --email , --username , or --all'); + console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com'); + process.exit(1); + } + + // Resolve target admins. + let targets; + if (all) { + targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled'); + } else { + const q = db('admin_users'); + if (email) q.where({ email }); + if (username) q.where({ username }); + targets = await q.select('id', 'username', 'email', 'two_factor_enabled'); + } + + if (targets.length === 0) { + console.error('❌ No matching admin user found.'); + process.exit(1); + } + + const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1); + console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`); + for (const t of targets) { + const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off'; + console.log(` - ${t.username} <${t.email}> [${flag}]`); + } + + const confirm = await ask('\nDisable MFA for the above? (yes/no): '); + const normalized = String(confirm).trim().toLowerCase(); + if (normalized !== 'yes' && normalized !== 'y') { + console.log('\n❌ Cancelled. No changes made.'); + process.exit(0); + } + + const ids = targets.map((t) => t.id); + const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR); + + for (const t of targets) { + try { + await logActivity('admin_mfa_reset_cli', + { admin_id: t.id, via: 'cli' }, + null, + { type: 'system', id: 0, name: 'reset-admin-mfa.js' } + ); + } catch (_) { /* activity log is best-effort */ } + } + + console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`); + process.exit(0); +} + +main().catch((err) => { + console.error('❌ Failed to reset MFA:', err.message); + process.exit(1); +}); diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index b2224825..898f029c 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors'); const { setAdminAuthCookie } = require('../utils/tokenUtils'); const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization'); +const mfaService = require('../services/mfaService'); const router = express.Router(); // Get admin profile @@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => { successResponse(res, { message: 'Logged out successfully' }); })); +// --------------------------------------------------------------------------- +// Multi-factor authentication (TOTP) — issue #738. +// +// All endpoints operate on the AUTHENTICATED admin's own account +// (req.admin.id) — enrollment is per-user and works for every role, +// super_admin included (closes #735). The TOTP secret is stored encrypted +// at rest and recovery codes are hashed; see services/mfaService.js. +// --------------------------------------------------------------------------- + +const isMfaEnabled = mfaService.isEnrolled; + +// Current MFA state for the logged-in admin. +router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => { + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + const enabled = isMfaEnabled(admin); + res.json({ + enabled, + enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null, + recoveryCodesRemaining: enabled + ? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length + : 0 + }); +})); + +// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet +// enabled), and return the otpauth URI + QR for the authenticator app. Calling +// this again before /enable simply regenerates the provisional secret. +router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => { + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (isMfaEnabled(admin)) { + throw new ConflictError('Two-factor authentication is already enabled'); + } + + const secret = mfaService.generateSecret(); + await db('admin_users').where('id', admin.id).update({ + two_factor_secret: mfaService.encryptSecret(secret), + two_factor_enabled: false, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date() + }); + + const accountName = admin.email || admin.username; + const otpauthUri = mfaService.buildOtpauthUri(accountName, secret); + const qr = await mfaService.buildQrDataUrl(otpauthUri); + + res.json({ + // `secret` is returned for manual entry when a QR can't be scanned. + secret, + otpauthUri, + qr, + issuer: mfaService.ISSUER, + account: accountName + }); +})); + +// Complete enrollment: verify a code against the provisional secret, enable +// MFA, and return one-time recovery codes (shown exactly once). +router.post('/mfa/enable', [ + adminAuth, + body('code').notEmpty().withMessage('Verification code is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (isMfaEnabled(admin)) { + throw new ConflictError('Two-factor authentication is already enabled'); + } + if (!admin.two_factor_secret) { + throw new ValidationError('Start setup before enabling two-factor authentication'); + } + if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) { + throw new ValidationError('Invalid verification code'); + } + + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + await db('admin_users').where('id', admin.id).update({ + two_factor_enabled: true, + two_factor_enrolled_at: new Date(), + two_factor_recovery_codes: JSON.stringify(hashed), + updated_at: new Date() + }); + + await logActivity('admin_mfa_enabled', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { + message: 'Two-factor authentication enabled', + recoveryCodes: plain + }); +})); + +// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session +// can't silently strip the second factor. +router.post('/mfa/disable', [ + adminAuth, + body('code').notEmpty().withMessage('A current code is required to disable 2FA') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (!isMfaEnabled(admin)) { + throw new ValidationError('Two-factor authentication is not enabled'); + } + + const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret); + let recoveryOk = false; + if (!totpOk) { + const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes); + recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched; + } + if (!totpOk && !recoveryOk) { + throw new ValidationError('Invalid verification code'); + } + + await db('admin_users').where('id', admin.id).update({ + two_factor_enabled: false, + two_factor_secret: null, + two_factor_recovery_codes: null, + two_factor_enrolled_at: null, + updated_at: new Date() + }); + + await logActivity('admin_mfa_disabled', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { message: 'Two-factor authentication disabled' }); +})); + +// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP +// code. Returns the new codes once. +router.post('/mfa/recovery-codes', [ + adminAuth, + body('code').notEmpty().withMessage('A current authenticator code is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const admin = await db('admin_users').where('id', req.admin.id).first(); + if (!admin) throw new NotFoundError('Admin user'); + if (!isMfaEnabled(admin)) { + throw new ValidationError('Two-factor authentication is not enabled'); + } + if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) { + throw new ValidationError('Invalid verification code'); + } + + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + await db('admin_users').where('id', admin.id).update({ + two_factor_recovery_codes: JSON.stringify(hashed), + updated_at: new Date() + }); + + await logActivity('admin_mfa_recovery_regenerated', + { admin_id: admin.id }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + successResponse(res, { + message: 'Recovery codes regenerated', + recoveryCodes: plain + }); +})); + module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index e99b5861..245bc27e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -2,9 +2,10 @@ const express = require('express'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { body, validationResult } = require('express-validator'); -const { db } = require('../database/db'); +const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { verifyRecaptcha } = require('../services/recaptcha'); +const mfaService = require('../services/mfaService'); const { trackFailedAttempt, trackSuccessfulLogin, @@ -32,6 +33,49 @@ const { } = require('../utils/passwordValidation'); const router = express.Router(); +/** + * Finish a successful admin login: reset the lockout counter, stamp + * last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the + * user payload. Shared by the direct (no-MFA) path and the MFA-verify path so + * both produce an identical session. `lockoutKey` is the identifier the user + * typed (username or email) so success/failure tracking stays in one bucket. + */ +async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) { + await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent); + + await db('admin_users').where('id', admin.id).update({ + last_login: new Date(), + last_login_ip: ipAddress + }); + + const token = jwt.sign({ + id: admin.id, + username: admin.username, + type: 'admin', + role: admin.role_name, + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + setAdminAuthCookie(res, token); + + return res.json({ + user: { + id: admin.id, + username: admin.username, + email: admin.email, + mustChangePassword: admin.must_change_password || false, + role: admin.role_name ? { + name: admin.role_name, + displayName: admin.role_display_name + } : null + } + }); +} + // Admin login with enhanced security router.post('/admin/login', [ body('username').notEmpty().trim(), @@ -94,49 +138,132 @@ router.post('/admin/login', [ return res.status(401).json({ error: getGenericAuthError() }); } - // Successful login - await trackSuccessfulLogin(username, ipAddress, userAgent); - - // Update last login and login metadata - await db('admin_users').where('id', admin.id).update({ - last_login: new Date(), - last_login_ip: ipAddress - }); - - // Generate token with additional claims including role - const token = jwt.sign({ - id: admin.id, - username: admin.username, - type: 'admin', - role: admin.role_name, // Add role to JWT - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - setAdminAuthCookie(res, token); - - // Token is delivered via HttpOnly cookie only (not in response body) - res.json({ - user: { + // Second factor: if this admin has TOTP enabled, do NOT complete the login + // yet. Issue a short-lived, single-purpose mfa_pending token and require the + // code via /admin/login/mfa. We deliberately don't reset the lockout counter + // (trackSuccessfulLogin) or stamp last_login until the second factor passes, + // so MFA brute-force is still gated by the account lockout. `loginId` carries + // the typed identifier so the verify step tracks the same lockout bucket. + if (mfaService.isEnrolled(admin)) { + const mfaToken = jwt.sign({ id: admin.id, username: admin.username, - email: admin.email, - mustChangePassword: admin.must_change_password || false, - role: admin.role_name ? { - name: admin.role_name, - displayName: admin.role_display_name - } : null - } - }); + type: 'mfa_pending', + loginId: username + }, process.env.JWT_SECRET, { + expiresIn: '5m', + issuer: 'picpeak-auth' + }); + return res.json({ mfaRequired: true, mfaToken }); + } + + return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username); } catch (error) { logger.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } }); +// Second-factor verification. Exchanges the short-lived mfa_pending token +// (from /admin/login) plus a TOTP or recovery code for a full admin session. +router.post('/admin/login/mfa', [ + body('mfaToken').notEmpty(), + body('code').notEmpty().trim() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { mfaToken, code } = req.body; + const ipAddress = getClientIp(req); + const userAgent = req.headers['user-agent'] || ''; + + let decoded; + try { + decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, { + algorithms: ['HS256'], + issuer: 'picpeak-auth' + }); + } catch (err) { + return res.status(401).json({ + error: 'Your verification session expired. Please sign in again.', + code: 'MFA_SESSION_EXPIRED' + }); + } + + if (decoded.type !== 'mfa_pending') { + return res.status(401).json({ error: getGenericAuthError() }); + } + + const lockoutKey = decoded.loginId || decoded.username; + const lockoutStatus = await checkAccountLockout(lockoutKey); + if (lockoutStatus.isLocked) { + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed attempts', + retryAfter: lockoutStatus.remainingTime + }); + } + + const admin = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', decoded.id) + .select( + 'admin_users.*', + 'roles.name as role_name', + 'roles.display_name as role_display_name' + ) + .first(); + + if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) { + return res.status(401).json({ error: getGenericAuthError() }); + } + + // TOTP first, then a one-time recovery code. + let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret); + let usedRecovery = false; + let remainingHashes = null; + if (!ok) { + const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes); + const result = await mfaService.consumeRecoveryCode(code, stored); + if (result.matched) { + ok = true; + usedRecovery = true; + remainingHashes = result.remainingHashes; + } + } + + if (!ok) { + await trackFailedAttempt(lockoutKey, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' }); + } + + if (usedRecovery) { + await db('admin_users').where('id', admin.id).update({ + two_factor_recovery_codes: JSON.stringify(remainingHashes), + updated_at: new Date() + }); + await logActivity('admin_mfa_recovery_used', + { admin_id: admin.id, remaining: remainingHashes.length }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + } + + await logActivity('admin_mfa_login', + { admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' }, + null, + { type: 'admin', id: admin.id, name: admin.username } + ); + + return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey); + } catch (error) { + logger.error('MFA verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + // Logout endpoint router.post('/logout', async (req, res) => { try { diff --git a/backend/src/services/mfaService.js b/backend/src/services/mfaService.js new file mode 100644 index 00000000..422a91a5 --- /dev/null +++ b/backend/src/services/mfaService.js @@ -0,0 +1,183 @@ +/** + * mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738). + * + * Responsibilities: + * - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so + * Google Authenticator / Authy / 1Password all work); + * - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't + * yield working authenticator seeds; + * - generate/verify one-time recovery codes, hashed (bcrypt) and single-use; + * - build the otpauth:// URI + QR data-URL for enrollment. + * + * The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set, + * otherwise from JWT_SECRET. Rotating either invalidates stored secrets — + * the same blast radius as rotating JWT_SECRET already has for sessions, and + * `reset-admin-mfa.js` is the recovery path. + */ + +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const { authenticator } = require('otplib'); +const QRCode = require('qrcode'); + +// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift. +authenticator.options = { window: 1 }; + +const ISSUER = 'PicPeak'; +const RECOVERY_CODE_COUNT = 10; +const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code +const RECOVERY_BCRYPT_ROUNDS = 10; + +const ENC_ALGO = 'aes-256-gcm'; +const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable + +function getEncryptionKey() { + const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET; + if (!material) { + throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set'); + } + return crypto.scryptSync(material, ENC_SALT, 32); +} + +/** Generate a fresh base32 TOTP secret. */ +function generateSecret() { + return authenticator.generateSecret(); +} + +/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */ +function encryptSecret(plainSecret) { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENC_ALGO, key, iv); + const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [iv, tag, ct].map((b) => b.toString('base64url')).join('.'); +} + +/** Reverse of encryptSecret. Throws on tamper/wrong key. */ +function decryptSecret(stored) { + const key = getEncryptionKey(); + const [ivB64, tagB64, ctB64] = String(stored).split('.'); + if (!ivB64 || !tagB64 || !ctB64) { + throw new Error('mfaService: malformed encrypted secret'); + } + const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url')); + decipher.setAuthTag(Buffer.from(tagB64, 'base64url')); + const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]); + return pt.toString('utf8'); +} + +/** Verify a 6-digit TOTP code against the (plaintext) secret. */ +function verifyTotp(code, plainSecret) { + if (!code || !plainSecret) return false; + try { + return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret }); + } catch { + return false; + } +} + +/** Verify a code against a STORED (encrypted) secret. */ +function verifyTotpEncrypted(code, storedSecret) { + try { + return verifyTotp(code, decryptSecret(storedSecret)); + } catch { + return false; + } +} + +/** otpauth:// URI for an authenticator app. */ +function buildOtpauthUri(accountName, plainSecret) { + return authenticator.keyuri(accountName, ISSUER, plainSecret); +} + +/** QR code (PNG data URL) for the otpauth URI. */ +async function buildQrDataUrl(otpauthUri) { + return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 }); +} + +/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */ +function formatRecoveryCode(raw) { + return raw.match(/.{1,4}/g).join('-'); +} + +/** + * Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes + * (shown to the admin ONCE) and their bcrypt hashes (persisted). + */ +async function generateRecoveryCodes() { + const plain = []; + const hashed = []; + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { + // base32-ish, lowercase, no ambiguous chars + const raw = crypto.randomBytes(RECOVERY_CODE_BYTES) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .toLowerCase() + .slice(0, 10); + const code = formatRecoveryCode(raw); + plain.push(code); + hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS)); + } + return { plain, hashed }; +} + +function normalizeRecoveryInput(code) { + return String(code || '').trim().toLowerCase(); +} + +/** + * Check a submitted recovery code against the stored hash array. On match, + * returns the remaining hashes (matched one removed — single use). On miss, + * matched:false and the array unchanged. + * + * @param {string[]} storedHashes + * @returns {Promise<{matched: boolean, remainingHashes: string[]}>} + */ +async function consumeRecoveryCode(code, storedHashes) { + const input = normalizeRecoveryInput(code); + const hashes = Array.isArray(storedHashes) ? storedHashes : []; + if (!input) return { matched: false, remainingHashes: hashes }; + for (let i = 0; i < hashes.length; i++) { + // eslint-disable-next-line no-await-in-loop + if (await bcrypt.compare(input, hashes[i])) { + const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1)); + return { matched: true, remainingHashes: remaining }; + } + } + return { matched: false, remainingHashes: hashes }; +} + +/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */ +function isEnrolled(admin) { + const v = admin && admin.two_factor_enabled; + return v === true || v === 1 || v === '1'; +} + +/** Parse the DB column (JSON text) into an array of hashes. */ +function parseRecoveryCodes(raw) { + if (!raw) return []; + try { + const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +module.exports = { + generateSecret, + encryptSecret, + decryptSecret, + verifyTotp, + verifyTotpEncrypted, + buildOtpauthUri, + buildQrDataUrl, + generateRecoveryCodes, + consumeRecoveryCode, + parseRecoveryCodes, + isEnrolled, + formatRecoveryCode, + ISSUER, + RECOVERY_CODE_COUNT, +}; From cdbfb514bd748f4f8974356562ef0aafad1e0893 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 11:37:51 +0200 Subject: [PATCH 18/19] test(auth): MFA unit + route + CLI coverage (39 tests) mfaService unit (encrypt/decrypt, TOTP, single-use recovery, isEnrolled), adminMfa HTTP (enroll/challenge/verify/recovery/disable; super_admin enrollment guards #735), and reset-admin-mfa.js CLI. --- .../integration/resetAdminMfaCli.test.js | 82 +++++ backend/__tests__/routes/adminMfa.test.js | 345 ++++++++++++++++++ backend/__tests__/services/mfaService.test.js | 193 ++++++++++ 3 files changed, 620 insertions(+) create mode 100644 backend/__tests__/integration/resetAdminMfaCli.test.js create mode 100644 backend/__tests__/routes/adminMfa.test.js create mode 100644 backend/__tests__/services/mfaService.test.js diff --git a/backend/__tests__/integration/resetAdminMfaCli.test.js b/backend/__tests__/integration/resetAdminMfaCli.test.js new file mode 100644 index 00000000..3aa3f2bb --- /dev/null +++ b/backend/__tests__/integration/resetAdminMfaCli.test.js @@ -0,0 +1,82 @@ +/** + * CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738). + * + * Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs + * the script in a child process (--email --yes) pointed at the same + * DB file, and asserts the four MFA columns are zeroed. The script runs in + * its own process with its own knex connection; the parent connection is + * idle during the spawn so the SQLite write lock isn't contended. + */ + +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { bootCrmDb } = require('./helpers/crmDb'); + +jest.setTimeout(60000); + +let db; +let cleanup; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); +}, 60000); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js'); + +async function seedEnrolledAdmin(email) { + const inserted = await db('admin_users').insert({ + username: email.split('@')[0], + email, + password_hash: 'x', + is_active: true, + two_factor_enabled: true, + two_factor_secret: 'iv.tag.ct', + two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']), + two_factor_enrolled_at: new Date(), + created_at: new Date(), + }).returning('id'); + return inserted[0]?.id ?? inserted[0]; +} + +it('zeroes the four MFA columns for the targeted admin', async () => { + const email = 'reset-me@example.com'; + const id = await seedEnrolledAdmin(email); + + execFileSync('node', [SCRIPT, '--email', email, '--yes'], { + env: { + ...process.env, + NODE_ENV: 'test', + TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH, + }, + stdio: 'pipe', + }); + + const row = await db('admin_users').where({ id }).first(); + expect(Number(row.two_factor_enabled)).toBe(0); + expect(row.two_factor_secret).toBeNull(); + expect(row.two_factor_recovery_codes).toBeNull(); + expect(row.two_factor_enrolled_at).toBeNull(); +}); + +it('leaves a different admin untouched', async () => { + const targetEmail = 'target@example.com'; + const bystanderEmail = 'bystander@example.com'; + const targetId = await seedEnrolledAdmin(targetEmail); + const bystanderId = await seedEnrolledAdmin(bystanderEmail); + + execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], { + env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH }, + stdio: 'pipe', + }); + + const target = await db('admin_users').where({ id: targetId }).first(); + const bystander = await db('admin_users').where({ id: bystanderId }).first(); + expect(Number(target.two_factor_enabled)).toBe(0); + expect(Number(bystander.two_factor_enabled)).toBe(1); + expect(bystander.two_factor_secret).toBe('iv.tag.ct'); +}); diff --git a/backend/__tests__/routes/adminMfa.test.js b/backend/__tests__/routes/adminMfa.test.js new file mode 100644 index 00000000..352f2a6e --- /dev/null +++ b/backend/__tests__/routes/adminMfa.test.js @@ -0,0 +1,345 @@ +/** + * HTTP-level tests for the admin TOTP MFA feature (#738). + * + * Two surfaces: + * 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable, + * GET /mfa/status, POST /mfa/disable — mounted like server.js at + * /api/admin/auth (src/routes/adminAuth.js). + * 2. Login challenge — POST /admin/login + POST /admin/login/mfa + * (src/routes/auth.js, mounted /api/auth). + * + * Uses the same real-SQLite harness as the CRM route tests + * (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are + * generated in-test via otplib's authenticator against the secret the + * /setup endpoint returns in plaintext. + * + * NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the + * first require of db.js — mirror adminCrmAuth.test.js exactly. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-')); +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite'); +process.env.STORAGE_PATH = path.join(tmpDir, 'storage'); +fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true }); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret'; +// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login +// tests don't need a token. Be explicit so a leaked env can't flip it on. +delete process.env.RECAPTCHA_SECRET_KEY; + +const request = require('supertest'); +const bcrypt = require('bcrypt'); +const { authenticator } = require('otplib'); + +const { + bootCrmDb, mintAdminToken, buildRouteApp, +} = require('../integration/helpers/crmDb'); + +jest.setTimeout(60000); + +let db; +let cleanup; +let adminApp; // /api/admin/auth (enrollment) +let authApp; // /api/auth (login challenge) + +/** + * Seed a bare admin (password known) and return its id + login creds. + * seedMinimal always creates username 'tester'; we need distinct rows per + * scenario, so insert directly with a unique username/email. + */ +async function seedAdmin({ username, superAdmin = false } = {}) { + const password = 'correct-horse'; + const passwordHash = await bcrypt.hash(password, 4); + const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`; + const row = { + username: uname, + email: `${uname}@example.com`, + password_hash: passwordHash, + must_change_password: false, + is_active: true, + created_at: new Date(), + }; + if (superAdmin) { + const role = await db('roles').where({ name: 'super_admin' }).first(); + if (!role) throw new Error('super_admin role not seeded'); + row.role_id = role.id; + } + const inserted = await db('admin_users').insert(row).returning('id'); + const id = inserted[0]?.id ?? inserted[0]; + return { id, username: uname, password }; +} + +/** Run the full setup→enable enrollment against the live app. Returns + * the plaintext TOTP secret (for later login codes) and recovery codes. */ +async function enroll(adminId) { + const token = mintAdminToken(adminId); + const setup = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + expect(setup.status).toBe(200); + const secret = setup.body.secret; + + const enable = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: authenticator.generate(secret) }); + expect(enable.status).toBe(200); + return { secret, recoveryCodes: enable.body.recoveryCodes, token }; +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth')); + authApp = buildRouteApp('/api/auth', require('../../src/routes/auth')); +}, 60000); + +afterAll(async () => { + if (cleanup) await cleanup(); +}); + +describe('MFA enrollment — /api/admin/auth/mfa/*', () => { + it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.secret).toEqual(expect.any(String)); + expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//); + expect(res.body.qr).toMatch(/^data:image\/png;base64,/); + + // Not yet enabled: status must still report disabled. + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + + // And the row stores an encrypted secret (not the plaintext one). + const row = await db('admin_users').where({ id: admin.id }).first(); + expect(row.two_factor_secret).toBeTruthy(); + expect(row.two_factor_secret).not.toBe(res.body.secret); + expect(Number(row.two_factor_enabled)).toBe(0); + }); + + it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => { + const admin = await seedAdmin(); + const { recoveryCodes, token } = await enroll(admin.id); + + expect(Array.isArray(recoveryCodes)).toBe(true); + expect(recoveryCodes).toHaveLength(10); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.status).toBe(200); + expect(status.body.enabled).toBe(true); + expect(status.body.recoveryCodesRemaining).toBe(10); + expect(status.body.enrolledAt).toBeTruthy(); + }); + + it('enable with a WRONG code is rejected (400) and MFA stays off', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + const setup = await request(adminApp) + .post('/api/admin/auth/mfa/setup') + .set('Authorization', `Bearer ${token}`); + const valid = authenticator.generate(setup.body.secret); + const wrong = valid === '000000' ? '111111' : '000000'; + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: wrong }); + expect(res.status).toBe(400); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + }); + + it('enable before setup is rejected', async () => { + const admin = await seedAdmin(); + const token = mintAdminToken(admin.id); + const res = await request(adminApp) + .post('/api/admin/auth/mfa/enable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '123456' }); + // No provisional secret → ValidationError (400). + expect(res.status).toBe(400); + }); + + it('all enrollment endpoints require a valid admin token (401 without one)', async () => { + const noToken = await request(adminApp).get('/api/admin/auth/mfa/status'); + expect(noToken.status).toBe(401); + const setup = await request(adminApp).post('/api/admin/auth/mfa/setup'); + expect(setup.status).toBe(401); + }); + + // Regression guard for #735: super_admin used to be blocked from enrolling. + // Enrollment operates on req.admin.id and is role-agnostic — assert a + // super_admin can complete the full setup→enable flow. + it('#735 regression — a super_admin can enroll in MFA', async () => { + const admin = await seedAdmin({ superAdmin: true }); + const { recoveryCodes, token } = await enroll(admin.id); + expect(recoveryCodes).toHaveLength(10); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(true); + }); +}); + +describe('MFA disable — /api/admin/auth/mfa/disable', () => { + it('requires a valid code; a wrong code is rejected and state persists', async () => { + const admin = await seedAdmin(); + const { token } = await enroll(admin.id); + + const bad = await request(adminApp) + .post('/api/admin/auth/mfa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }); + expect(bad.status).toBe(400); + + const stillOn = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(stillOn.body.enabled).toBe(true); + }); + + it('a valid TOTP disables MFA and clears the stored secret', async () => { + const admin = await seedAdmin(); + const { secret, token } = await enroll(admin.id); + + const res = await request(adminApp) + .post('/api/admin/auth/mfa/disable') + .set('Authorization', `Bearer ${token}`) + .send({ code: authenticator.generate(secret) }); + expect(res.status).toBe(200); + + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${token}`); + expect(status.body.enabled).toBe(false); + expect(status.body.recoveryCodesRemaining).toBe(0); + + const row = await db('admin_users').where({ id: admin.id }).first(); + expect(row.two_factor_secret).toBeNull(); + expect(row.two_factor_recovery_codes).toBeNull(); + }); +}); + +describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => { + it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => { + const admin = await seedAdmin(); + await enroll(admin.id); + + const res = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + + expect(res.status).toBe(200); + expect(res.body.mfaRequired).toBe(true); + expect(res.body.mfaToken).toEqual(expect.any(String)); + expect(res.body.user).toBeUndefined(); // no completed session + // No admin auth cookie should have been set on the challenge response. + const cookies = res.headers['set-cookie'] || []; + expect(cookies.join(';')).not.toMatch(/adminToken/i); + }); + + it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => { + const admin = await seedAdmin(); + const res = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + expect(res.status).toBe(200); + expect(res.body.mfaRequired).toBeUndefined(); + expect(res.body.user).toBeDefined(); + expect(res.body.user.username).toBe(admin.username); + }); + + it('login/mfa with a valid TOTP completes the session', async () => { + const admin = await seedAdmin(); + const { secret } = await enroll(admin.id); + + const challenge = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const { mfaToken } = challenge.body; + + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken, code: authenticator.generate(secret) }); + + expect(res.status).toBe(200); + expect(res.body.user).toBeDefined(); + expect(res.body.user.id).toBe(admin.id); + }); + + it('login/mfa with a wrong code is 401 MFA_INVALID', async () => { + const admin = await seedAdmin(); + const { secret } = await enroll(admin.id); + const challenge = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + + const valid = authenticator.generate(secret); + const wrong = valid === '000000' ? '111111' : '000000'; + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: challenge.body.mfaToken, code: wrong }); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MFA_INVALID'); + expect(res.body.user).toBeUndefined(); + }); + + it('a recovery code logs in and is then single-use (second use fails)', async () => { + const admin = await seedAdmin(); + const { recoveryCodes } = await enroll(admin.id); + const recovery = recoveryCodes[0]; + + // First challenge + recovery-code exchange succeeds. + const c1 = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const first = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: c1.body.mfaToken, code: recovery }); + expect(first.status).toBe(200); + expect(first.body.user).toBeDefined(); + + // recoveryCodesRemaining dropped by one. + const status = await request(adminApp) + .get('/api/admin/auth/mfa/status') + .set('Authorization', `Bearer ${mintAdminToken(admin.id)}`); + expect(status.body.recoveryCodesRemaining).toBe(9); + + // Second use of the SAME recovery code must fail. + const c2 = await request(authApp) + .post('/api/auth/admin/login') + .send({ username: admin.username, password: admin.password }); + const second = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: c2.body.mfaToken, code: recovery }); + expect(second.status).toBe(401); + expect(second.body.code).toBe('MFA_INVALID'); + }); + + it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => { + const admin = await seedAdmin(); + await enroll(admin.id); + const res = await request(authApp) + .post('/api/auth/admin/login/mfa') + .send({ mfaToken: mintAdminToken(admin.id), code: '123456' }); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/__tests__/services/mfaService.test.js b/backend/__tests__/services/mfaService.test.js new file mode 100644 index 00000000..a6e5b36b --- /dev/null +++ b/backend/__tests__/services/mfaService.test.js @@ -0,0 +1,193 @@ +/** + * Unit tests for mfaService — admin TOTP MFA (#738). + * + * Pure unit: no DB, no Express. Exercises the crypto/verification surface + * directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt + * derivation has key material (the service derives the AES key from + * MFA_ENCRYPTION_KEY, falling back to JWT_SECRET). + */ + +// Must be set BEFORE the service is required — the key is derived lazily per +// call, but keep it explicit and stable so encrypt/decrypt round-trips. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret'; +delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET + +const { authenticator } = require('otplib'); +const mfaService = require('../../src/services/mfaService'); + +describe('mfaService — secret encryption (AES-256-GCM)', () => { + it('round-trips encrypt → decrypt to the original secret', () => { + const secret = mfaService.generateSecret(); + const blob = mfaService.encryptSecret(secret); + expect(blob).toEqual(expect.any(String)); + expect(blob).not.toContain(secret); // stored form is not plaintext + expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext + expect(mfaService.decryptSecret(blob)).toBe(secret); + }); + + it('produces a different ciphertext each time (random IV) but decrypts identically', () => { + const secret = mfaService.generateSecret(); + const a = mfaService.encryptSecret(secret); + const b = mfaService.encryptSecret(secret); + expect(a).not.toBe(b); + expect(mfaService.decryptSecret(a)).toBe(secret); + expect(mfaService.decryptSecret(b)).toBe(secret); + }); + + it('throws when decrypting a malformed blob (wrong segment count)', () => { + expect(() => mfaService.decryptSecret('garbage')).toThrow(); + expect(() => mfaService.decryptSecret('only.two')).toThrow(); + }); + + it('throws when the auth tag / ciphertext is tampered with', () => { + const secret = mfaService.generateSecret(); + const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.'); + // Flip a character in the ciphertext → GCM auth check must fail. + const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA'); + expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow(); + }); +}); + +describe('mfaService — TOTP verification', () => { + it('accepts a freshly generated code for the plaintext secret', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotp(code, secret)).toBe(true); + }); + + it('tolerates whitespace in the submitted code', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true); + }); + + it('rejects a wrong code', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + const wrong = code === '000000' ? '111111' : '000000'; + expect(mfaService.verifyTotp(wrong, secret)).toBe(false); + }); + + it('returns false for empty inputs rather than throwing', () => { + const secret = mfaService.generateSecret(); + expect(mfaService.verifyTotp('', secret)).toBe(false); + expect(mfaService.verifyTotp('123456', '')).toBe(false); + expect(mfaService.verifyTotp(null, secret)).toBe(false); + }); + + it('verifies through the encrypted blob (verifyTotpEncrypted)', () => { + const secret = mfaService.generateSecret(); + const stored = mfaService.encryptSecret(secret); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true); + + const wrong = code === '000000' ? '111111' : '000000'; + expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false); + }); + + it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => { + const secret = mfaService.generateSecret(); + const code = authenticator.generate(secret); + expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false); + }); +}); + +describe('mfaService — otpauth URI / QR', () => { + it('builds an otpauth:// URI containing issuer, account and secret', () => { + const secret = mfaService.generateSecret(); + const uri = mfaService.buildOtpauthUri('admin@example.com', secret); + expect(uri).toMatch(/^otpauth:\/\/totp\//); + expect(uri).toContain(encodeURIComponent(mfaService.ISSUER)); + expect(uri).toContain(`secret=${secret}`); + }); + + it('builds a PNG data-URL QR for the URI', async () => { + const secret = mfaService.generateSecret(); + const uri = mfaService.buildOtpauthUri('admin@example.com', secret); + const qr = await mfaService.buildQrDataUrl(uri); + expect(qr).toMatch(/^data:image\/png;base64,/); + }); +}); + +describe('mfaService — recovery codes', () => { + it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT); + expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT); + expect(new Set(plain).size).toBe(10); + expect(new Set(hashed).size).toBe(10); + // Hashes are bcrypt, not the plaintext. + hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/)); + plain.forEach((p) => expect(hashed).not.toContain(p)); + }); + + it('formats a raw code into 4-char groups', () => { + expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij'); + }); + + it('consumes a valid recovery code once and removes it (single-use)', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + const target = plain[3]; + + const first = await mfaService.consumeRecoveryCode(target, hashed); + expect(first.matched).toBe(true); + expect(first.remainingHashes).toHaveLength(9); + + // Reusing the same code against the reduced set must now fail. + const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes); + expect(reuse.matched).toBe(false); + expect(reuse.remainingHashes).toHaveLength(9); + }); + + it('matches case-insensitively and trims whitespace', async () => { + const { plain, hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed); + expect(res.matched).toBe(true); + }); + + it('rejects a wrong code and leaves the hash set unchanged', async () => { + const { hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed); + expect(res.matched).toBe(false); + expect(res.remainingHashes).toHaveLength(10); + }); + + it('handles empty / missing input safely', async () => { + const { hashed } = await mfaService.generateRecoveryCodes(); + const res = await mfaService.consumeRecoveryCode('', hashed); + expect(res.matched).toBe(false); + expect(res.remainingHashes).toBe(hashed); + const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null); + expect(noHashes.matched).toBe(false); + expect(noHashes.remainingHashes).toEqual([]); + }); +}); + +describe('mfaService — parseRecoveryCodes', () => { + it('parses a JSON string array', () => { + expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']); + }); + it('passes an already-array through', () => { + expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']); + }); + it('returns [] for null / garbage / non-array JSON', () => { + expect(mfaService.parseRecoveryCodes(null)).toEqual([]); + expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]); + expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]); + }); +}); + +describe('mfaService — isEnrolled coercion', () => { + it('treats true / 1 / "1" as enrolled', () => { + expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true); + expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true); + expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true); + }); + it('treats false / 0 / null / missing as not enrolled', () => { + expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false); + expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false); + expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false); + expect(mfaService.isEnrolled({})).toBe(false); + expect(mfaService.isEnrolled(null)).toBe(false); + }); +}); From 96e3c68b9d6b35a82abcad664a6da7b19150b4fd Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 3 Jul 2026 11:44:07 +0200 Subject: [PATCH 19/19] feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for #738. - mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account): per-user setup (QR + manual secret + verify), recovery codes shown once (copy/download/confirm), status, regenerate, disable. Renders for super_admin (closes #735). - Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a code step (TOTP or recovery), call /auth/admin/login/mfa; handle MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout. - Removed the non-functional global enable_2fa checkbox from SecurityTab (and its persistence) — replaced with a note pointing to per-user setup. - en + de i18n. Verified live in-browser: enroll (QR→code→recovery codes), logout, and the two-step challenge into the dashboard as super_admin. --- .../settings/components/MfaSettingsCard.tsx | 320 ++++++++++++++++++ .../settings/hooks/useSettingsState.ts | 3 - .../src/features/settings/tabs/GeneralTab.tsx | 5 + .../features/settings/tabs/SecurityTab.tsx | 20 +- frontend/src/i18n/locales/de.json | 61 +++- frontend/src/i18n/locales/en.json | 61 +++- frontend/src/pages/admin/AdminLoginPage.tsx | 152 ++++++++- frontend/src/services/auth.service.ts | 17 +- frontend/src/services/mfa.service.ts | 50 +++ frontend/src/types/index.ts | 14 + 10 files changed, 679 insertions(+), 24 deletions(-) create mode 100644 frontend/src/features/settings/components/MfaSettingsCard.tsx create mode 100644 frontend/src/services/mfa.service.ts diff --git a/frontend/src/features/settings/components/MfaSettingsCard.tsx b/frontend/src/features/settings/components/MfaSettingsCard.tsx new file mode 100644 index 00000000..7bf0a58d --- /dev/null +++ b/frontend/src/features/settings/components/MfaSettingsCard.tsx @@ -0,0 +1,320 @@ +import React, { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react'; + +import { Button, Card, Input, Loading, useConfirm } from '../../../components/common'; +import { mfaService } from '../../../services/mfa.service'; + +// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own +// account surface (Settings → General → Admin Account). Self-service: acts on +// the currently authenticated admin only. + +interface RecoveryCodesPanelProps { + codes: string[]; + onConfirm: () => void; +} + +const RecoveryCodesPanel: React.FC = ({ codes, onConfirm }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + + const asText = codes.join('\n'); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(asText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(t('settings.mfa.copyFailed')); + } + }; + + const handleDownload = () => { + const blob = new Blob([`${asText}\n`], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'picpeak-recovery-codes.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ +

{t('settings.mfa.recoveryCodesWarning')}

+
+ +
+ {codes.map((code) => ( + {code} + ))} +
+ +
+ + +
+ + + + +
+ ); +}; + +export const MfaSettingsCard: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + + const { data: status, isLoading } = useQuery({ + queryKey: ['admin-mfa-status'], + queryFn: () => mfaService.getStatus(), + }); + + // Enrollment flow state + const [setupData, setSetupData] = useState> | null>(null); + const [enableCode, setEnableCode] = useState(''); + const [enableError, setEnableError] = useState(null); + + // Recovery codes to display once (after enable or regenerate) + const [recoveryCodes, setRecoveryCodes] = useState(null); + + // Regenerate flow state + const [showRegenerate, setShowRegenerate] = useState(false); + const [regenerateCode, setRegenerateCode] = useState(''); + const [regenerateError, setRegenerateError] = useState(null); + + const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] }); + + const errorMessage = (error: any, fallbackKey: string): string => + error?.response?.data?.error || t(fallbackKey); + + const setupMutation = useMutation({ + mutationFn: () => mfaService.setup(), + onSuccess: (data) => { + setSetupData(data); + setEnableCode(''); + setEnableError(null); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')), + }); + + const enableMutation = useMutation({ + mutationFn: (code: string) => mfaService.enable(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setSetupData(null); + setEnableCode(''); + setEnableError(null); + invalidateStatus(); + }, + onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')), + }); + + const disableMutation = useMutation({ + mutationFn: (code: string) => mfaService.disable(code), + onSuccess: () => { + toast.success(t('settings.mfa.disabledToast')); + invalidateStatus(); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')), + }); + + const regenerateMutation = useMutation({ + mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setShowRegenerate(false); + setRegenerateCode(''); + setRegenerateError(null); + invalidateStatus(); + }, + onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')), + }); + + const handleDisable = async () => { + const code = window.prompt(t('settings.mfa.disablePrompt')); + if (code === null) return; + const trimmed = code.trim(); + if (!trimmed) { + toast.error(t('settings.mfa.codeRequired')); + return; + } + const ok = await confirm({ + title: t('settings.mfa.disableConfirmTitle'), + message: t('settings.mfa.disableConfirmMessage'), + variant: 'danger', + confirmLabel: t('settings.mfa.disableConfirmButton'), + }); + if (ok) disableMutation.mutate(trimmed); + }; + + return ( + +
+ +

{t('settings.mfa.title')}

+
+

{t('settings.mfa.description')}

+ + {isLoading ? ( +
+ +
+ ) : recoveryCodes ? ( + setRecoveryCodes(null)} /> + ) : status?.enabled ? ( + /* ---------------- Enrolled ---------------- */ +
+
+ + {t('settings.mfa.enabledBadge')} +
+ +

+ {t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })} +

+ + {showRegenerate ? ( +
+

{t('settings.mfa.regenerateHelp')}

+ { + setRegenerateCode(e.target.value); + if (regenerateError) setRegenerateError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={regenerateError || undefined} + autoComplete="one-time-code" + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : setupData ? ( + /* ---------------- Setup in progress ---------------- */ +
+

{t('settings.mfa.setupScanInstruction')}

+
+ {t('settings.mfa.qrAlt')} +
+

{t('settings.mfa.manualEntry')}

+ + {setupData.secret} + +
+
+ +
+ + { + setEnableCode(e.target.value); + if (enableError) setEnableError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={enableError || undefined} + inputMode="numeric" + autoComplete="one-time-code" + /> +
+ +
+ + +
+
+ ) : ( + /* ---------------- Not enrolled ---------------- */ +
+

{t('settings.mfa.notEnrolled')}

+ +
+ )} +
+ ); +}; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 7344907e..b8977d55 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -35,7 +35,6 @@ export interface GeneralSettings { export interface SecuritySettings { password_min_length: number; password_complexity: string; - enable_2fa: boolean; session_timeout_minutes: number; max_login_attempts: number; attempt_window_minutes: number; @@ -134,7 +133,6 @@ export function useSettingsState() { const [securitySettings, setSecuritySettings] = useState({ password_min_length: 8, password_complexity: 'moderate', - enable_2fa: false, session_timeout_minutes: 60, max_login_attempts: 5, attempt_window_minutes: 15, @@ -231,7 +229,6 @@ export function useSettingsState() { setSecuritySettings({ password_min_length: toNumber(settings.security_password_min_length, 8), password_complexity: settings.security_password_complexity ?? 'moderate', - enable_2fa: toBoolean(settings.security_enable_2fa, false), session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60), max_login_attempts: toNumber(settings.security_max_login_attempts, 5), attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 85e63de3..d250570e 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { GeneralSettings } from '../hooks/useSettingsState'; import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState'; import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx"; +import { MfaSettingsCard } from '../components/MfaSettingsCard'; interface GeneralTabProps { generalSettings: GeneralSettings; @@ -94,6 +95,10 @@ export const GeneralTab: React.FC = ({ )} + {/* Per-user two-factor authentication (issue #738) — lives beside the + admin's own account details rather than the admin-wide Security tab. */} + +

{t('settings.general.siteConfiguration')}

diff --git a/frontend/src/features/settings/tabs/SecurityTab.tsx b/frontend/src/features/settings/tabs/SecurityTab.tsx index 79675bf0..cb338f13 100644 --- a/frontend/src/features/settings/tabs/SecurityTab.tsx +++ b/frontend/src/features/settings/tabs/SecurityTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Save, Key, AlertCircle } from 'lucide-react'; +import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react'; import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; import type { SecuritySettings } from '../hooks/useSettingsState'; @@ -124,15 +124,15 @@ export const SecurityTab: React.FC = ({
- +
+
+ +
+

{t('settings.security.twoFactorTitle')}

+

{t('settings.security.twoFactorNote')}

+
+
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3b761a57..633af451 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1422,13 +1422,14 @@ "attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden", "lockoutDurationMinutes": "Sperrdauer (Minuten)", "lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben", - "enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren", "recaptchaSettings": "reCAPTCHA-Einstellungen", "enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren", "siteKey": "Site-Schlüssel", "secretKey": "Geheimer Schlüssel", "recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von", - "saveSecuritySettings": "Sicherheitseinstellungen speichern" + "saveSecuritySettings": "Sicherheitseinstellungen speichern", + "twoFactorTitle": "Zwei-Faktor-Authentifizierung", + "twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung." }, "events": { "title": "Veranstaltungserstellung", @@ -2040,6 +2041,42 @@ "testSend": "Test senden", "testSending": "Senden…", "testSentToast": "Testnachricht gesendet (ID: {{id}})." + }, + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).", + "notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.", + "setUp": "Einrichten", + "setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).", + "manualEntry": "Oder gib diesen Schlüssel manuell ein:", + "qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung", + "enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein", + "codePlaceholder": "123456", + "enable": "Aktivieren", + "enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.", + "recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.", + "recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.", + "regenerate": "Wiederherstellungscodes neu erzeugen", + "regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.", + "regenerateConfirm": "Neu erzeugen", + "disable": "Deaktivieren", + "disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:", + "disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?", + "disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.", + "disableConfirmButton": "Deaktivieren", + "disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.", + "codeRequired": "Ein Code ist erforderlich.", + "recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.", + "recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.", + "copy": "Kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.", + "download": ".txt herunterladen", + "done": "Fertig", + "setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.", + "enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.", + "disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.", + "regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut." } }, "branding": { @@ -3585,7 +3622,25 @@ "generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "needHelp": "Hilfe benötigt? Kontakt", "poweredBy": "Bereitgestellt von PicPeak", - "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123" + "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123", + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.", + "codeLabel": "Authentifizierungscode", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Wiederherstellungscode", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Bestätigen", + "back": "Zurück", + "useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden", + "useAuthenticator": "Stattdessen Authenticator-App verwenden", + "codeRequired": "Gib deinen Authentifizierungscode ein", + "invalidCode": "Ungültiger Code. Bitte versuche es erneut.", + "sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.", + "locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.", + "lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut." + } }, "cssTemplates": { "title": "Benutzerdefinierte CSS-Vorlagen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3ba9cb3c..fbb30759 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -969,13 +969,14 @@ "attemptWindowMinutesHelp": "How long to look back when counting failed login attempts", "lockoutDurationMinutes": "Lockout Duration (minutes)", "lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures", - "enable2FA": "Enable two-factor authentication for admins", "recaptchaSettings": "reCAPTCHA Settings", "enableRecaptcha": "Enable reCAPTCHA for login forms", "siteKey": "Site Key", "secretKey": "Secret Key", "recaptchaHelp": "Get your reCAPTCHA keys from", - "saveSecuritySettings": "Save Security Settings" + "saveSecuritySettings": "Save Security Settings", + "twoFactorTitle": "Two-factor authentication", + "twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login." }, "categories": { "title": "Categories", @@ -1587,6 +1588,42 @@ "testSend": "Send test", "testSending": "Sending…", "testSentToast": "Test message sent (id: {{id}})." + }, + "mfa": { + "title": "Two-factor authentication", + "description": "Add a second step to your admin sign-in using an authenticator app (TOTP).", + "notEnrolled": "Two-factor authentication is not enabled for your account.", + "setUp": "Set up", + "setupScanInstruction": "Scan this QR code with your authenticator app (e.g. Google Authenticator, 1Password, Authy).", + "manualEntry": "Or enter this secret manually:", + "qrAlt": "Two-factor setup QR code", + "enterCodeLabel": "Enter the 6-digit code from your app", + "codePlaceholder": "123456", + "enable": "Enable", + "enabledBadge": "Two-factor authentication is enabled.", + "recoveryCodesRemaining": "{{count}} recovery code remaining.", + "recoveryCodesRemaining_other": "{{count}} recovery codes remaining.", + "regenerate": "Regenerate recovery codes", + "regenerateHelp": "Enter a current authentication code to generate a new set of recovery codes. Your old codes will stop working.", + "regenerateConfirm": "Regenerate", + "disable": "Disable", + "disablePrompt": "Enter a current authentication or recovery code to disable two-factor authentication:", + "disableConfirmTitle": "Disable two-factor authentication?", + "disableConfirmMessage": "Your account will no longer require a second step at sign-in. You can re-enable it at any time.", + "disableConfirmButton": "Disable", + "disabledToast": "Two-factor authentication disabled.", + "codeRequired": "A code is required.", + "recoveryCodesWarning": "Save these recovery codes now. Each can be used once if you lose access to your authenticator app. They will not be shown again.", + "recoveryCodesAck": "I have saved my recovery codes in a safe place.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Failed to copy to clipboard.", + "download": "Download .txt", + "done": "Done", + "setupFailed": "Could not start two-factor setup. Please try again.", + "enableFailed": "Could not enable two-factor authentication. Check the code and try again.", + "disableFailed": "Could not disable two-factor authentication. Check the code and try again.", + "regenerateFailed": "Could not regenerate recovery codes. Check the code and try again." } }, "analytics": { @@ -3481,7 +3518,25 @@ "generalError": "An error occurred. Please try again.", "needHelp": "Need help? Contact", "poweredBy": "Powered by PicPeak", - "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123" + "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123", + "mfa": { + "title": "Two-factor authentication", + "subtitle": "Enter the 6-digit code from your authenticator app.", + "recoverySubtitle": "Enter one of your recovery codes.", + "codeLabel": "Authentication code", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Recovery code", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Verify", + "back": "Back", + "useRecoveryCode": "Use a recovery code instead", + "useAuthenticator": "Use your authenticator app instead", + "codeRequired": "Enter your authentication code", + "invalidCode": "Invalid code. Please try again.", + "sessionExpired": "Your verification session expired. Please sign in again.", + "locked": "Account temporarily locked due to too many attempts. Try again later.", + "lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds." + } }, "slideshow": { "adminTitle": "Live Slideshow", diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 5a482708..52173d69 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; +import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; +import { isMfaChallenge } from '../../types'; import { setupService } from '../../services/setup.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; @@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => { const [loginSuccess, setLoginSuccess] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(null); + // Two-step MFA challenge state (issue #738). When the first step returns + // { mfaRequired, mfaToken } we swap the form to a code entry step. + const [step, setStep] = useState<'credentials' | 'mfa'>('credentials'); + const [mfaToken, setMfaToken] = useState(null); + const [mfaCode, setMfaCode] = useState(''); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); + const [mfaError, setMfaError] = useState(null); + const { data: settingsData } = usePublicSettings(); const { isDark } = useAdminDarkMode(); @@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => { ...formData, recaptchaToken }); + // MFA enabled → move to the second step instead of logging in. + if (isMfaChallenge(response)) { + setMfaToken(response.mfaToken); + setMfaCode(''); + setUseRecoveryCode(false); + setMfaError(null); + setStep('mfa'); + return; + } login(response.token, response.user); toast.success(t('adminLogin.loginSuccess')); setLoginSuccess(true); @@ -142,6 +160,62 @@ export const AdminLoginPage: React.FC = () => { } }; + const backToCredentials = () => { + setStep('credentials'); + setMfaToken(null); + setMfaCode(''); + setMfaError(null); + setUseRecoveryCode(false); + }; + + const handleMfaSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + toast.dismiss(); + + const code = mfaCode.trim(); + if (!code) { + setMfaError(t('adminLogin.mfa.codeRequired')); + return; + } + if (!mfaToken) { + // Token lost somehow — restart the flow. + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + return; + } + + setIsLoading(true); + setMfaError(null); + + try { + const response = await authService.adminLoginMfa({ mfaToken, code }); + login(response.token, response.user); + toast.success(t('adminLogin.loginSuccess')); + setLoginSuccess(true); + } catch (error: any) { + const data = error.response?.data; + const code = data?.code; + if (error.response?.status === 423) { + const retryAfter = data?.retryAfter; + toast.error( + retryAfter + ? t('adminLogin.mfa.lockedRetry', { seconds: retryAfter }) + : t('adminLogin.mfa.locked') + ); + backToCredentials(); + } else if (code === 'MFA_SESSION_EXPIRED') { + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + } else if (code === 'MFA_INVALID') { + setMfaError(t('adminLogin.mfa.invalidCode')); + } else { + setMfaError(data?.error || t('adminLogin.generalError')); + } + } finally { + setIsLoading(false); + } + }; + return (
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => { {/* Login Form */} + {step === 'credentials' ? (
{/* Form Error */} {errors.form && ( @@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => { {t('adminLogin.signIn')} + ) : ( +
+
+
+ +
+

+ {t('adminLogin.mfa.title')} +

+

+ {useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')} +

+
+ + {mfaError && ( +
+ +

{mfaError}

+
+ )} + +
+ + { + setMfaCode(e.target.value); + if (mfaError) setMfaError(null); + }} + placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')} + leftIcon={} + inputMode={useRecoveryCode ? 'text' : 'numeric'} + autoComplete="one-time-code" + autoFocus + /> +
+ + + +
+ + +
+ + )}
{/* Footer */} diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 46d8add5..ff081149 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { api } from '../config/api'; -import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types'; +import type { LoginResponse, AdminLoginResponse, GalleryAuthResponse, AdminUser } from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({ @@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes export const authService = { // Admin authentication - async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { - // Backend expects 'username' field, but we accept email - const response = await api.post('/auth/admin/login', { + async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { + // Backend expects 'username' field, but we accept email. + // Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }. + const response = await api.post('/auth/admin/login', { username: credentials.email, password: credentials.password, recaptchaToken: credentials.recaptchaToken @@ -24,6 +25,14 @@ export const authService = { return response.data; }, + // Second step of the two-step admin login. `code` accepts a 6-digit TOTP + // or a recovery code (e.g. "awzq-jca3-va"). On success the session cookie + // is set server-side and the user object is returned. + async adminLoginMfa(payload: { mfaToken: string; code: string }): Promise { + const response = await api.post('/auth/admin/login/mfa', payload); + return response.data; + }, + async adminLogout() { try { await api.post('/auth/logout'); diff --git a/frontend/src/services/mfa.service.ts b/frontend/src/services/mfa.service.ts new file mode 100644 index 00000000..0324d619 --- /dev/null +++ b/frontend/src/services/mfa.service.ts @@ -0,0 +1,50 @@ +import { api } from '../config/api'; + +// Per-user admin TOTP MFA (issue #738). All endpoints operate on the +// currently authenticated admin's own account. + +export interface MfaStatus { + enabled: boolean; + enrolledAt: string | null; + recoveryCodesRemaining: number; +} + +export interface MfaSetupResponse { + secret: string; + otpauthUri: string; + qr: string; // PNG data URL + issuer: string; + account: string; +} + +export interface MfaRecoveryCodesResponse { + message: string; + recoveryCodes: string[]; +} + +export const mfaService = { + async getStatus(): Promise { + const response = await api.get('/admin/auth/mfa/status'); + return response.data; + }, + + async setup(): Promise { + const response = await api.post('/admin/auth/mfa/setup'); + return response.data; + }, + + async enable(code: string): Promise { + const response = await api.post('/admin/auth/mfa/enable', { code }); + return response.data; + }, + + async disable(code: string): Promise<{ message: string }> { + const response = await api.post<{ message: string }>('/admin/auth/mfa/disable', { code }); + return response.data; + }, + + async regenerateRecoveryCodes(code: string): Promise { + const response = await api.post('/admin/auth/mfa/recovery-codes', { code }); + return response.data; + }, +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b84491bf..a33955a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,6 +252,20 @@ export interface LoginResponse { user: AdminUser; } +// Two-step admin login: when MFA is enabled, POST /auth/admin/login returns +// this challenge instead of a session (no cookie yet). The mfaToken is a +// short-lived (5 min) JWT exchanged at POST /auth/admin/login/mfa. +export interface MfaChallengeResponse { + mfaRequired: true; + mfaToken: string; +} + +export type AdminLoginResponse = LoginResponse | MfaChallengeResponse; + +export function isMfaChallenge(res: AdminLoginResponse): res is MfaChallengeResponse { + return (res as MfaChallengeResponse).mfaRequired === true; +} + export interface GalleryAuthResponse { token: string; event: {