diff --git a/CHANGELOG.md b/CHANGELOG.md index 373580d..fcd21f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state. +### Removed +- The Web Applications list no longer shows a "Version" column or an "Update" button, because Stalwart does not expose the installed version of each web application and `/latest/` GitHub URLs hide it. + ## [1.0.7] - 2026-07-29 ### Added diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index f6520fd..6ab2755 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -28,7 +28,6 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat'; -import { WebAppVersionCell } from '@/features/webapps/WebAppVersionCell'; import { DropdownMenu, DropdownMenuTrigger, @@ -355,15 +354,10 @@ export function DynamicList({ viewName }: DynamicListProps) { return { obj, schema: schem, list }; }, [schema, viewName]); - // The Web Applications list gets an extra client-side column showing the - // installed version of the WebUI app (and an update action when a newer - // release exists), which the server does not expose as an object property. - const isAppList = viewName === 'x:Application'; const displayColumns = useMemo(() => { const columns = resolved?.list?.columns ?? []; - if (!isAppList) return columns; - return [...columns, { name: 'x:webuiVersion', label: t('webApps.version', 'Version') }]; - }, [resolved?.list?.columns, isAppList, t]); + return columns; + }, [resolved?.list?.columns]); const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(null); @@ -431,7 +425,7 @@ export function DynamicList({ viewName }: DynamicListProps) { try { const accountId = getAccountId(obj.objectName); - const properties = ['id', ...list.columns.map((c) => c.name), ...(isAppList ? ['resourceUrl'] : [])]; + const properties = ['id', ...list.columns.map((c) => c.name)]; const filter = buildFilter(); const sortArr = buildSort(); @@ -481,7 +475,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setLoading(false); } }, - [resolved, schema, buildFilter, buildSort, isAppList, t], + [resolved, schema, buildFilter, buildSort], ); useEffect(() => { @@ -1296,10 +1290,7 @@ export function DynamicList({ viewName }: DynamicListProps) { )} {displayColumns.map((col) => ( - {col.name === 'x:webuiVersion' ? ( - - ) : ( - renderCellValue( + {renderCellValue( item[col.name], fields[col.name], col.name, @@ -1307,7 +1298,7 @@ export function DynamicList({ viewName }: DynamicListProps) { resolved.obj.objectName, getDisplayName, ) - )} + } ))} {hasItemActions && {renderItemActions(item)}} diff --git a/src/features/webapps/WebAppVersionCell.tsx b/src/features/webapps/WebAppVersionCell.tsx deleted file mode 100644 index 9bb86f5..0000000 --- a/src/features/webapps/WebAppVersionCell.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ArrowUpCircle, Loader2 } from 'lucide-react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { jmapSet, getAccountId } from '@/services/jmap/client'; -import { useToast } from '@/hooks/use-toast'; -import { isUpdateAvailable, normalizeVersion } from '@/lib/versionCompare'; -import type { JmapSetResponse } from '@/types/jmap'; - -const WEBUI_REPO = 'stalwartlabs/webui'; -const GITHUB_LATEST_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/latest\//; -const LATEST_CACHE_TTL_MS = 5 * 60 * 1000; - -// The GitHub API is rate-limited per IP, so latest-release lookups are shared -// and deduplicated across rows and remounts. -const latestReleaseCache = new Map>(); - -function fetchLatestTag(repo: string): Promise { - const cached = latestReleaseCache.get(repo); - if (cached) return cached; - const promise = (async () => { - try { - const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { - headers: { Accept: 'application/vnd.github+json' }, - }); - if (!response.ok) return null; - const data = (await response.json()) as { tag_name?: unknown }; - return typeof data.tag_name === 'string' ? data.tag_name : null; - } catch { - return null; - } finally { - setTimeout(() => latestReleaseCache.delete(repo), LATEST_CACHE_TTL_MS); - } - })(); - latestReleaseCache.set(repo, promise); - return promise; -} - -interface WebAppVersionCellProps { - resourceUrl: unknown; -} - -export function WebAppVersionCell({ resourceUrl }: WebAppVersionCellProps) { - const { t } = useTranslation(); - const { toast } = useToast(); - const repo = typeof resourceUrl === 'string' ? GITHUB_LATEST_RE.exec(resourceUrl)?.[1] : undefined; - // The running bundle's version is only meaningful for the WebUI application - // itself; other apps do not expose their installed version. - const currentVersion = repo === WEBUI_REPO ? __APP_VERSION__ : null; - const [latestVersion, setLatestVersion] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); - const [updating, setUpdating] = useState(false); - - useEffect(() => { - if (!repo) return; - let cancelled = false; - fetchLatestTag(repo).then((tag) => { - if (!cancelled && tag) setLatestVersion(tag); - }); - return () => { - cancelled = true; - }; - }, [repo]); - - if (!repo || !currentVersion) { - return ; - } - - const updateAvailable = latestVersion !== null && isUpdateAvailable(latestVersion, currentVersion); - - async function handleUpdate() { - setUpdating(true); - try { - const accountId = getAccountId('x:Action'); - const responses = await jmapSet('x:Action', accountId, { - create: { 'action-0': { '@type': 'UpdateApps' } }, - }); - const result = responses[responses.length - 1][1] as unknown as JmapSetResponse; - if (result.created?.['action-0']) { - toast({ - title: t('webApps.updateStarted', 'Update started'), - description: t( - 'webApps.updateStartedBody', - 'The server is downloading the latest version. Reload the page in a few seconds to use it.', - ), - }); - } else { - const err = result.notCreated?.['action-0']; - throw new Error(err?.description ?? err?.type ?? t('webApps.updateRejected', 'The server rejected the update.')); - } - } catch (error) { - toast({ - variant: 'destructive', - title: t('webApps.updateFailed', 'Update failed'), - description: error instanceof Error ? error.message : String(error), - }); - } finally { - setUpdating(false); - } - } - - return ( -
e.stopPropagation()}> - v{normalizeVersion(currentVersion)} - {latestVersion && !updateAvailable && ( - {t('webApps.upToDate', 'Up to date')} - )} - {updateAvailable && latestVersion && ( - <> - - - - - {t('webApps.confirmTitle', 'Update application?')} - - {t( - 'webApps.confirmBody', - 'The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.', - { from: normalizeVersion(currentVersion), to: normalizeVersion(latestVersion) }, - )} - - - - {t('common.cancel', 'Cancel')} - {t('webApps.confirm', 'Update')} - - - - - )} -
- ); -} diff --git a/src/i18n/en.json b/src/i18n/en.json index e515ab0..441e3d9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -397,17 +397,5 @@ "failedToLoad": "Failed to load", "noGetResponse": "No get response", "objectNotFound": "Object not found" - }, - "webApps": { - "confirm": "Update", - "confirmBody": "The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.", - "confirmTitle": "Update application?", - "updateFailed": "Update failed", - "updateRejected": "The server rejected the update.", - "updateStarted": "Update started", - "updateStartedBody": "The server is downloading the latest version. Reload the page in a few seconds to use it.", - "updateTo": "Update to v{{version}}", - "upToDate": "Up to date", - "version": "Version" } } diff --git a/src/lib/versionCompare.test.ts b/src/lib/versionCompare.test.ts deleted file mode 100644 index 721805e..0000000 --- a/src/lib/versionCompare.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -import { describe, expect, it } from 'vitest'; -import { compareVersions, isUpdateAvailable, normalizeVersion } from './versionCompare'; - -describe('normalizeVersion', () => { - it('strips a leading v prefix and whitespace', () => { - expect(normalizeVersion('v1.2.3')).toBe('1.2.3'); - expect(normalizeVersion('V2.0')).toBe('2.0'); - expect(normalizeVersion(' 1.0.6 ')).toBe('1.0.6'); - }); -}); - -describe('compareVersions', () => { - it('orders dotted numeric versions', () => { - expect(compareVersions('v1.0.7', '1.0.6')).toBeGreaterThan(0); - expect(compareVersions('1.0.6', 'v1.0.7')).toBeLessThan(0); - expect(compareVersions('1.2.0', '1.2')).toBe(0); - }); - - it('returns null for unparsable versions', () => { - expect(compareVersions('nightly', '1.0.6')).toBeNull(); - }); -}); - -describe('isUpdateAvailable', () => { - it('is false when latest equals current', () => { - expect(isUpdateAvailable('v1.0.6', '1.0.6')).toBe(false); - }); - - it('is true when latest is newer', () => { - expect(isUpdateAvailable('v1.1.0', '1.0.6')).toBe(true); - }); - - it('is false when latest is older', () => { - expect(isUpdateAvailable('v1.0.5', '1.0.6')).toBe(false); - }); - - it('falls back to string difference for unparsable tags', () => { - expect(isUpdateAvailable('nightly-2', '1.0.6')).toBe(true); - expect(isUpdateAvailable('v1.0.6', '1.0.6-beta')).toBe(true); - }); -}); diff --git a/src/lib/versionCompare.ts b/src/lib/versionCompare.ts deleted file mode 100644 index ba24500..0000000 --- a/src/lib/versionCompare.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -export function normalizeVersion(version: string): string { - return version.trim().replace(/^v/i, ''); -} - -// Compares two dotted numeric versions; returns null when either is unparsable. -export function compareVersions(a: string, b: string): number | null { - const pa = normalizeVersion(a).split('.').map(Number); - const pb = normalizeVersion(b).split('.').map(Number); - if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null; - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const diff = (pa[i] ?? 0) - (pb[i] ?? 0); - if (diff !== 0) return diff; - } - return 0; -} - -// Falls back to a string difference when the versions are not dotted numerics, -// so an unexpected tag format still surfaces as an available update. -export function isUpdateAvailable(latestVersion: string, currentVersion: string): boolean { - const cmp = compareVersions(latestVersion, currentVersion); - if (cmp === null) return normalizeVersion(latestVersion) !== normalizeVersion(currentVersion); - return cmp > 0; -}