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) => {