diff --git a/client/src/App.tsx b/client/src/App.tsx index a45351f1..4179c1a9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,7 +88,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { @@ -99,6 +99,7 @@ export default function App() { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) + if (config?.version) setAppVersion(config.version) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index e4b9a578..02008da2 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -16,8 +16,8 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b const { t, language } = useTranslation() const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [expanded, setExpanded] = useState({}) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState>({}) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index b4fb5bd4..a6bd7a92 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -27,14 +27,13 @@ interface Addon { } export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { - const { user, logout, isPrerelease } = useAuthStore() + const { user, logout, isPrerelease, appVersion } = useAuthStore() const { settings, updateSetting } = useSettingsStore() const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const navigate = useNavigate() const location = useLocation() const [userMenuOpen, setUserMenuOpen] = useState(false) - const [appVersion, setAppVersion] = useState(null) const darkMode = settings.dark_mode const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: if (user) loadAddons() }, [user, location.pathname]) - useEffect(() => { - import('../../api/client').then(({ authApi }) => { - authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) - }) - }, []) - const handleLogout = () => { logout() navigate('/login', { state: { noRedirect: true } }) diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 291677e7..f677889b 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -23,6 +23,7 @@ interface AuthState { demoMode: boolean devMode: boolean isPrerelease: boolean + appVersion: string hasMapsKey: boolean serverTimezone: string /** Server policy: all users must enable MFA */ @@ -43,6 +44,7 @@ interface AuthState { setDemoMode: (val: boolean) => void setDevMode: (val: boolean) => void setIsPrerelease: (val: boolean) => void + setAppVersion: (val: string) => void setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void @@ -61,6 +63,7 @@ export const useAuthStore = create((set, get) => ({ demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, isPrerelease: false, + appVersion: '', hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, @@ -226,6 +229,7 @@ export const useAuthStore = create((set, get) => ({ setDevMode: (val: boolean) => set({ devMode: val }), setIsPrerelease: (val: boolean) => set({ isPrerelease: val }), + setAppVersion: (val: string) => set({ appVersion: val }), setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index e31ab346..3a5a9321 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -24,7 +24,8 @@ export function compareVersions(a: string, b: string): number { const parse = (v: string) => { const [base, pre] = v.split('-pre.'); const parts = base.split('.').map(Number); - const preN = pre !== undefined ? parseInt(pre, 10) : null; + const n = pre !== undefined ? parseInt(pre, 10) : null; + const preN = n !== null && Number.isFinite(n) ? n : null; return { parts, preN }; }; const pa = parse(a), pb = parse(b); @@ -315,59 +316,63 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } } -const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -let _versionCache: { data: object; expiresAt: number } | null = null; +interface VersionInfo { + current: string; + latest: string; + update_available: boolean; + release_url?: string; + is_docker: boolean; + is_prerelease: boolean; +} -export async function checkVersion() { +const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +let _versionCache: { data: VersionInfo; expiresAt: number } | null = null; + +export async function checkVersion(): Promise { if (_versionCache && Date.now() < _versionCache.expiresAt) { return _versionCache.data; } const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version; const isPrerelease = currentVersion.includes('-pre.'); - const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; - let result: object = fallback; + const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; + let result: VersionInfo; try { if (isPrerelease) { // Fetch release list and find the newest prerelease const resp = await fetch( - 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20', + 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100', { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } ); if (!resp.ok) { - result = fallback; - } else { - const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>; - const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : []; - if (!prereleases.length) { - result = fallback; - } else { - // Sort by version descending and pick highest - const sorted = prereleases.sort((a, b) => compareVersions( - (b.tag_name || '').replace(/^v/, ''), - (a.tag_name || '').replace(/^v/, '') - )); - const latest = (sorted[0].tag_name || '').replace(/^v/, ''); - const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; - result = { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true }; - } + return fallback; } + const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>; + const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : []; + if (!prereleases.length) { + return fallback; + } + // Pre-compute stripped versions, then sort descending + const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') })); + tagged.sort((a, b) => compareVersions(b.v, a.v)); + const latest = tagged[0].v; + const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; + result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true }; } else { const resp = await fetch( 'https://api.github.com/repos/mauriceboe/TREK/releases/latest', { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } ); if (!resp.ok) { - result = fallback; - } else { - const data = await resp.json() as { tag_name?: string; html_url?: string }; - const latest = (data.tag_name || '').replace(/^v/, ''); - const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; - result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false }; + return fallback; } + const data = await resp.json() as { tag_name?: string; html_url?: string }; + const latest = (data.tag_name || '').replace(/^v/, ''); + const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; + result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false }; } } catch { - result = fallback; + return fallback; } _versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL }; @@ -389,7 +394,7 @@ export async function checkAndNotifyVersion(): Promise { actorId: null, scope: 'admin', targetId: 0, - params: { version: result.latest as string }, + params: { version: result.latest }, }); } catch { // Silently ignore — version check is non-critical