diff --git a/client/package-lock.json b/client/package-lock.json
index 10635b0..7402f98 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nomad-client",
- "version": "2.5.1",
+ "version": "2.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-client",
- "version": "2.5.1",
+ "version": "2.5.2",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
diff --git a/client/package.json b/client/package.json
index 1462833..4279793 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "nomad-client",
- "version": "2.5.2",
+ "version": "2.5.3",
"private": true,
"type": "module",
"scripts": {
diff --git a/client/src/api/client.js b/client/src/api/client.js
index dffb805..a820ef6 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -129,6 +129,8 @@ export const adminApi = {
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
+ checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
+ installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
}
export const addonsApi = {
diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js
index 551ec7e..6e4ebb4 100644
--- a/client/src/i18n/translations/de.js
+++ b/client/src/i18n/translations/de.js
@@ -263,6 +263,21 @@ const de = {
'admin.addons.toast.updated': 'Addon aktualisiert',
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
'admin.addons.noAddons': 'Keine Addons verfügbar',
+ 'admin.update.available': 'Update verfügbar',
+ 'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
+ 'admin.update.button': 'Auf GitHub ansehen',
+ 'admin.update.install': 'Update installieren',
+ 'admin.update.confirmTitle': 'Update installieren?',
+ 'admin.update.confirmText': 'NOMAD wird von {current} auf {version} aktualisiert. Der Server startet danach automatisch neu.',
+ 'admin.update.dataInfo': 'Alle Daten (Reisen, Benutzer, API-Schlüssel, Uploads, Vacay, Atlas, Budgets) bleiben erhalten.',
+ 'admin.update.warning': 'Die App ist während des Neustarts kurz nicht erreichbar.',
+ 'admin.update.confirm': 'Jetzt aktualisieren',
+ 'admin.update.installing': 'Wird aktualisiert…',
+ 'admin.update.success': 'Update installiert! Server startet neu…',
+ 'admin.update.failed': 'Update fehlgeschlagen',
+ 'admin.update.backupHint': 'Wir empfehlen, vor dem Update ein Backup zu erstellen und herunterzuladen.',
+ 'admin.update.backupLink': 'Zum Backup',
+ 'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
// Vacay addon
'vacay.subtitle': 'Urlaubstage planen und verwalten',
diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js
index 4d88d37..3dea650 100644
--- a/client/src/i18n/translations/en.js
+++ b/client/src/i18n/translations/en.js
@@ -263,6 +263,21 @@ const en = {
'admin.addons.toast.updated': 'Addon updated',
'admin.addons.toast.error': 'Failed to update addon',
'admin.addons.noAddons': 'No addons available',
+ 'admin.update.available': 'Update available',
+ 'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
+ 'admin.update.button': 'View on GitHub',
+ 'admin.update.install': 'Install Update',
+ 'admin.update.confirmTitle': 'Install Update?',
+ 'admin.update.confirmText': 'NOMAD will be updated from {current} to {version}. The server will restart automatically afterwards.',
+ 'admin.update.dataInfo': 'All your data (trips, users, API keys, uploads, Vacay, Atlas, budgets) will be preserved.',
+ 'admin.update.warning': 'The app will be briefly unavailable during the restart.',
+ 'admin.update.confirm': 'Update Now',
+ 'admin.update.installing': 'Updating…',
+ 'admin.update.success': 'Update installed! Server is restarting…',
+ 'admin.update.failed': 'Update failed',
+ 'admin.update.backupHint': 'We recommend creating a backup before updating.',
+ 'admin.update.backupLink': 'Go to Backup',
+ 'admin.update.reloadHint': 'Please reload the page in a few seconds.',
// Vacay addon
'vacay.subtitle': 'Plan and manage vacation days',
diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx
index d2c1f44..6b35edf 100644
--- a/client/src/pages/AdminPage.jsx
+++ b/client/src/pages/AdminPage.jsx
@@ -10,7 +10,7 @@ import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import AddonManager from '../components/Admin/AddonManager'
-import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
+import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
@@ -49,6 +49,12 @@ export default function AdminPage() {
const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({})
+ // Version check & update
+ const [updateInfo, setUpdateInfo] = useState(null)
+ const [showUpdateModal, setShowUpdateModal] = useState(false)
+ const [updating, setUpdating] = useState(false)
+ const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
+
const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -58,6 +64,9 @@ export default function AdminPage() {
loadAppConfig()
loadApiKeys()
adminApi.getOidc().then(setOidcConfig).catch(() => {})
+ adminApi.checkVersion().then(data => {
+ if (data.update_available) setUpdateInfo(data)
+ }).catch(() => {})
}, [])
const loadData = async () => {
@@ -95,6 +104,26 @@ export default function AdminPage() {
}
}
+ const handleInstallUpdate = async () => {
+ setUpdating(true)
+ setUpdateResult(null)
+ try {
+ await adminApi.installUpdate()
+ setUpdateResult('success')
+ // Server is restarting — poll until it comes back, then reload
+ const poll = setInterval(async () => {
+ try {
+ await authApi.getAppConfig()
+ clearInterval(poll)
+ window.location.reload()
+ } catch { /* still restarting */ }
+ }, 2000)
+ } catch {
+ setUpdateResult('error')
+ setUpdating(false)
+ }
+ }
+
const handleToggleRegistration = async (value) => {
setAllowRegistration(value)
try {
@@ -222,6 +251,43 @@ export default function AdminPage() {
+ {/* Update Banner */}
+ {updateInfo && (
+
+
+
+
+
{t('admin.update.available')}
+
+ {t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
+
+
+
+
+ {updateInfo.release_url && (
+
+
+ {t('admin.update.button')}
+
+ )}
+
+
+
+ )}
+
{/* Demo Baseline Button */}
{demoMode && (
@@ -744,6 +810,137 @@ export default function AdminPage() {
)}
+
+ {/* Update confirmation popup — matches backup restore style */}
+ {showUpdateModal && (
+ { if (!updating) setShowUpdateModal(false) }}
+ >
+
e.stopPropagation()}
+ style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
+ className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
+ >
+ {updateResult === 'success' ? (
+ <>
+
+
+
+
+
+
{t('admin.update.success')}
+
+
+
+
+
{t('admin.update.reloadHint')}
+
+ >
+ ) : updateResult === 'error' ? (
+ <>
+
+
+
+
+
+
{t('admin.update.failed')}
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {/* Red header */}
+
+
+
+
{t('admin.update.confirmTitle')}
+
+ v{updateInfo?.current} → v{updateInfo?.latest}
+
+
+
+
+ {/* Body */}
+
+
+ {updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
+
+
+
+
+
+ {t('admin.update.dataInfo')}
+
+
+
+
+
+
+
+ {t('admin.update.backupHint')}{' '}
+
+
+
+
+
+
+
+
+
{t('admin.update.warning')}
+
+
+
+
+ {/* Footer */}
+
+
+
+
+ >
+ )}
+
+
+ )}
)
}
diff --git a/server/package.json b/server/package.json
index 2fac206..4f2b156 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "nomad-server",
- "version": "2.5.2",
+ "version": "2.5.3",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
diff --git a/server/src/db/database.js b/server/src/db/database.js
index d03f120..19ea015 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -35,6 +35,10 @@ function initDb() {
maps_api_key TEXT,
unsplash_api_key TEXT,
openweather_api_key TEXT,
+ avatar TEXT,
+ oidc_sub TEXT,
+ oidc_issuer TEXT,
+ last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -55,6 +59,8 @@ function initDb() {
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
+ cover_image TEXT,
+ is_archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -65,6 +71,7 @@ function initDb() {
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
+ title TEXT,
UNIQUE(trip_id, day_number)
);
@@ -73,6 +80,7 @@ function initDb() {
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -153,6 +161,7 @@ function initDb() {
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
+ reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
@@ -171,6 +180,8 @@ function initDb() {
location TEXT,
confirmation_number TEXT,
notes TEXT,
+ status TEXT DEFAULT 'pending',
+ type TEXT DEFAULT 'other',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -314,16 +325,17 @@ function initDb() {
const versionRow = _db.prepare('SELECT version FROM schema_version').get();
let currentVersion = versionRow?.version ?? 0;
- // Existing DBs already have all pre-v2.5.2 columns — detect and skip
+ // Existing or fresh DBs may already have columns the migrations add.
+ // Detect by checking for a column from migration 1 (unsplash_api_key).
if (currentVersion === 0) {
- const hasLastLogin = _db.prepare(
- "SELECT 1 FROM pragma_table_info('users') WHERE name = 'last_login'"
+ const hasUnsplash = _db.prepare(
+ "SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'"
).get();
- if (hasLastLogin) {
- // DB was already fully migrated by the old try/catch pattern
+ if (hasUnsplash) {
+ // All columns from CREATE TABLE already exist — skip ALTER migrations
currentVersion = 19;
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion);
- console.log('[DB] Existing database detected, setting schema version to', currentVersion);
+ console.log('[DB] Schema already up-to-date, setting version to', currentVersion);
} else {
_db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0);
}
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index f241e93..ca9fab0 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -1,5 +1,7 @@
const express = require('express');
const bcrypt = require('bcryptjs');
+const { execSync } = require('child_process');
+const path = require('path');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
@@ -152,6 +154,78 @@ router.post('/save-demo-baseline', (req, res) => {
}
});
+// ── Version check ──────────────────────────────────────────
+
+router.get('/version-check', async (req, res) => {
+ const { version: currentVersion } = require('../../package.json');
+ try {
+ const resp = await fetch(
+ 'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
+ { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
+ );
+ if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
+ const data = await resp.json();
+ const latest = (data.tag_name || '').replace(/^v/, '');
+ const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
+ res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '' });
+ } catch {
+ res.json({ current: currentVersion, latest: currentVersion, update_available: false });
+ }
+});
+
+function compareVersions(a, b) {
+ const pa = a.split('.').map(Number);
+ const pb = b.split('.').map(Number);
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ const na = pa[i] || 0, nb = pb[i] || 0;
+ if (na > nb) return 1;
+ if (na < nb) return -1;
+ }
+ return 0;
+}
+
+// POST /api/admin/update — pull latest code, install deps, restart
+router.post('/update', async (req, res) => {
+ const rootDir = path.resolve(__dirname, '../../..');
+ const serverDir = path.resolve(__dirname, '../..');
+ const clientDir = path.join(rootDir, 'client');
+ const steps = [];
+
+ try {
+ // 1. git pull
+ const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' });
+ steps.push({ step: 'git pull', success: true, output: pullOutput.trim() });
+
+ // 2. npm install server
+ execSync('npm install --production', { cwd: serverDir, timeout: 120000, encoding: 'utf8' });
+ steps.push({ step: 'npm install (server)', success: true });
+
+ // 3. npm install + build client (production only)
+ if (process.env.NODE_ENV === 'production') {
+ execSync('npm install', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
+ execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
+ steps.push({ step: 'npm install + build (client)', success: true });
+ }
+
+ // Read new version
+ delete require.cache[require.resolve('../../package.json')];
+ const { version: newVersion } = require('../../package.json');
+ steps.push({ step: 'version', version: newVersion });
+
+ // 4. Send response before restart
+ res.json({ success: true, steps, restarting: true });
+
+ // 5. Graceful restart — exit and let process manager (Docker/systemd/pm2) restart
+ setTimeout(() => {
+ console.log('[Update] Restarting after update...');
+ process.exit(0);
+ }, 1000);
+ } catch (err) {
+ steps.push({ step: 'error', success: false, output: err.message });
+ res.status(500).json({ success: false, steps });
+ }
+});
+
// ── Addons ─────────────────────────────────────────────────
router.get('/addons', (req, res) => {