v2.5.3 — Admin update checker & one-click self-update

- Add version check against GitHub releases in admin dashboard
- Show amber banner when a newer version is available
- One-click update: git pull + npm install + auto-restart
- Confirmation dialog with backup recommendation and data safety info
- Dark mode support for update banner
- Fix fresh DB migration: initial schema now includes all columns
- i18n: English + German translations for all update UI
This commit is contained in:
Maurice
2026-03-23 19:02:08 +01:00
parent f4d3542d99
commit aeb530515e
9 changed files with 326 additions and 11 deletions

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "nomad-client",
"version": "2.5.2",
"version": "2.5.3",
"private": true,
"type": "module",
"scripts": {

View File

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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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() {
</div>
</div>
{/* Update Banner */}
{updateInfo && (
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
<ArrowUpCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
<ExternalLink className="w-3.5 h-3.5" />
{t('admin.update.button')}
</a>
)}
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4" />
{t('admin.update.install')}
</button>
</div>
</div>
)}
{/* Demo Baseline Button */}
{demoMode && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between">
@@ -744,6 +810,137 @@ export default function AdminPage() {
</div>
)}
</Modal>
{/* Update confirmation popup — matches backup restore style */}
{showUpdateModal && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => { if (!updating) setShowUpdateModal(false) }}
>
<div
onClick={e => 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' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #16a34a, #15803d)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<CheckCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.success')}</h3>
</div>
</div>
<div style={{ padding: '20px 24px', textAlign: 'center' }}>
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2" style={{ color: 'var(--text-muted)' }} />
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('admin.update.reloadHint')}</p>
</div>
</>
) : updateResult === 'error' ? (
<>
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<XCircle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.failed')}</h3>
</div>
</div>
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button
onClick={() => { setShowUpdateModal(false); setUpdateResult(null) }}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
</div>
</>
) : (
<>
{/* Red header */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>{t('admin.update.confirmTitle')}</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
v{updateInfo?.current} v{updateInfo?.latest}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{updateInfo && t('admin.update.confirmText').replace('{current}', `v${updateInfo.current}`).replace('{version}', `v${updateInfo.latest}`)}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
>
<div className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
>
<div className="flex items-start gap-2">
<Download className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>
{t('admin.update.backupHint')}{' '}
<button
onClick={() => { setShowUpdateModal(false); setActiveTab('backup') }}
className="underline font-semibold hover:text-blue-950 dark:hover:text-blue-100"
>{t('admin.update.backupLink')}</button>
</span>
</div>
</div>
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
<div className="flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
<span>{t('admin.update.warning')}</span>
</div>
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowUpdateModal(false)}
disabled={updating}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={handleInstallUpdate}
disabled={updating}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200 disabled:opacity-60 flex items-center gap-2"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{updating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{updating ? t('admin.update.installing') : t('admin.update.confirm')}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
)
}