From a83e75a79f222313f61e1f080f3af4578a911abc Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 05:47:58 +0200 Subject: [PATCH 01/89] Code-split feature pages and admin shell with React.lazy The dashboard (recharts, ~120 kB gzip), tracing, troubleshoot, actions and bootstrap wizard now load on demand, and the admin panel is split out of the entry chunk so anonymous visitors only download the login page (~134 kB gzip instead of ~557 kB). --- src/App.tsx | 6 ++++- src/components/common/LoadingFallback.tsx | 24 +++++++++++++++++ src/components/layout/MainContent.tsx | 32 ++++++++++++++++++----- src/main.tsx | 2 +- src/pages/AdminPanel.lazy.tsx | 12 +++++++++ src/pages/AdminPanel.tsx | 13 ++++++--- 6 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/components/common/LoadingFallback.tsx create mode 100644 src/pages/AdminPanel.lazy.tsx diff --git a/src/App.tsx b/src/App.tsx index 57fc917..1c815e6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,14 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; import { Toaster } from '@/components/ui/toaster'; export default function App() { return ( - + }> + + ); diff --git a/src/components/common/LoadingFallback.tsx b/src/components/common/LoadingFallback.tsx new file mode 100644 index 0000000..e7b107e --- /dev/null +++ b/src/components/common/LoadingFallback.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface LoadingFallbackProps { + fullScreen?: boolean; +} + +export function LoadingFallback({ fullScreen }: LoadingFallbackProps) { + const { t } = useTranslation(); + return ( +
+
+ +

{t('common.loading')}

+
+
+ ); +} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 4b04c83..7706f2d 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useSchemaStore } from '@/stores/schemaStore'; import { useCacheStore } from '@/stores/cacheStore'; import { useAccountStore } from '@/stores/accountStore'; @@ -12,11 +12,23 @@ import { resolveObject } from '@/lib/schemaResolver'; import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; -import { DashboardView } from '@/features/dashboard/components/DashboardView'; -import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage'; -import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage'; -import { TraceDetailView } from '@/features/tracing/components/TraceDetailView'; -import { ActionPage } from '@/features/actions/ActionPage'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; + +// Heavy or rarely used feature pages are code-split so the initial bundle +// stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own). +const DashboardView = lazy(() => + import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })), +); +const DeliveryTracePage = lazy(() => + import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })), +); +const LiveTracingPage = lazy(() => + import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })), +); +const TraceDetailView = lazy(() => + import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })), +); +const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage }))); interface MainContentProps { viewName?: string; @@ -25,6 +37,14 @@ interface MainContentProps { } export function MainContent({ viewName, id, section }: MainContentProps) { + return ( + }> + + + ); +} + +function MainContentView({ viewName, id, section }: MainContentProps) { const schema = useSchemaStore((s) => s.schema); const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists); diff --git a/src/main.tsx b/src/main.tsx index 5cfa5ce..06656fa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,8 +12,8 @@ import './index.css'; import App from './App'; import LoginPage from './pages/LoginPage'; import OAuthCallback from './pages/OAuthCallback'; -import AdminPanel from './pages/AdminPanel'; import NotFound from './pages/NotFound'; +import { AdminPanel } from './pages/AdminPanel.lazy'; import { ProtectedRoute } from './components/layout/ProtectedRoute'; import { getBasePath } from './lib/basePath'; diff --git a/src/pages/AdminPanel.lazy.tsx b/src/pages/AdminPanel.lazy.tsx new file mode 100644 index 0000000..03a6a3d --- /dev/null +++ b/src/pages/AdminPanel.lazy.tsx @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { lazy } from 'react'; + +// The admin panel (and everything reachable from it) is split out of the +// entry chunk so anonymous visitors only download the login page. +// Kept in a dedicated file so main.tsx stays component-free for fast refresh. +export const AdminPanel = lazy(() => import('./AdminPanel')); diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 76e80ac..7959abd 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -import { useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/stores/authStore'; @@ -17,7 +17,7 @@ import { TopBar } from '@/components/layout/TopBar'; import { Sidebar } from '@/components/layout/Sidebar'; import { MainContent } from '@/components/layout/MainContent'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; -import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, @@ -27,6 +27,11 @@ import { import { usePermissions } from '@/hooks/usePermissions'; import { Loader2 } from 'lucide-react'; +// Bootstrap mode is a rare first-run flow; keep it out of the main chunk. +const BootstrapWizard = lazy(() => + import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })), +); + export default function AdminPanel() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -237,7 +242,9 @@ export default function AdminPanel() { if (isBootstrapMode) { return ( - + }> + + ); } From 4739f9ed923bc0fe66b6a5062b187cbe3f7d1241 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 05:48:07 +0200 Subject: [PATCH 02/89] Proxy API and JMAP requests to a local Stalwart server in dev Same-origin proxying to localhost:8080 avoids CORS without weakening the server's CORS policy; pair with VITE_API_BASE_URL= (empty) in .env.development.local. --- vite.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 89348f5..cbe722c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,14 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + // Same-origin proxy to the local Stalwart container: avoids CORS entirely + // (VITE_API_BASE_URL stays empty in .env.development.local). + proxy: { + '/api': { target: 'http://localhost:8080', changeOrigin: true, ws: true }, + '/jmap': { target: 'http://localhost:8080', changeOrigin: true, ws: true }, + }, + }, test: { globals: false, environment: 'happy-dom', From 7c97297ce64bbfc8d95cde1bfa62e93d09fd6187 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 06:02:18 +0200 Subject: [PATCH 03/89] Add Ctrl+K/Cmd+K command palette search Replace the TopBar dropdown search with a cmdk-based command palette opened via a visible trigger button (with platform-aware shortcut badge) or the Ctrl+K/Cmd+K global shortcut. Shared search logic moves to a useGlobalSearch hook; the palette reuses the existing ui/command components. --- src/components/common/CommandPalette.tsx | 90 +++++++++ src/components/common/GlobalSearch.tsx | 245 ----------------------- src/components/layout/TopBar.tsx | 43 ++-- src/hooks/useGlobalSearch.ts | 136 +++++++++++++ src/i18n/en.json | 5 +- 5 files changed, 261 insertions(+), 258 deletions(-) create mode 100644 src/components/common/CommandPalette.tsx delete mode 100644 src/components/common/GlobalSearch.tsx create mode 100644 src/hooks/useGlobalSearch.ts diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx new file mode 100644 index 0000000..878de89 --- /dev/null +++ b/src/components/common/CommandPalette.tsx @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const { t } = useTranslation(); + const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]); + const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette); + + const GROUP_LABELS: Record = { + link: t('globalSearch.pages', 'Pages'), + form: t('globalSearch.formSections', 'Form Sections'), + field: t('globalSearch.fields', 'Fields'), + }; + + useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + return ( + + + {t('globalSearch.title', 'Search')} + + + + + {query.trim() + ? t('globalSearch.noResults', 'No results found.') + : t('globalSearch.typeToSearch', 'Type to search the admin panel.')} + + {Array.from(groups.entries()).map(([type, entries]) => ( + + {entries.map((entry) => { + const flatIdx = results.indexOf(entry); + const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; + const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); + + return ( + selectEntry(entry)} + > + +
+ {friendlyName(entry.text)} + {entry.breadcrumb} +
+ {actionLabel} +
+ ); + })} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/common/GlobalSearch.tsx b/src/components/common/GlobalSearch.tsx deleted file mode 100644 index 435a380..0000000 --- a/src/components/common/GlobalSearch.tsx +++ /dev/null @@ -1,245 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { Search, List, Settings, Plus } from 'lucide-react'; -import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { resolveObject } from '@/lib/schemaResolver'; -import type { Schema } from '@/types/schema'; - -const MAX_RESULTS = 15; - -const TYPE_ORDER: Record = { - link: 0, - form: 1, - field: 2, -}; - -function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null { - const resolved = resolveObject(schema, viewName); - if (!resolved) return null; - return resolved.objectType.type === 'singleton' ? 'singleton' : 'object'; -} - -function getActionInfo( - entryType: SearchIndexEntry['type'], - objectKind: 'singleton' | 'object' | null, - t: (key: string, fallback: string) => string, -): { label: string; Icon: typeof List } { - if (entryType === 'link') { - return objectKind === 'singleton' - ? { label: t('globalSearch.settings', 'Settings'), Icon: Settings } - : { label: t('globalSearch.list', 'List'), Icon: List }; - } - return objectKind === 'singleton' - ? { label: t('globalSearch.settings', 'Settings'), Icon: Settings } - : { label: t('globalSearch.create', 'Create'), Icon: Plus }; -} - -function getNavigationPath( - entryType: SearchIndexEntry['type'], - objectKind: 'singleton' | 'object' | null, - section: string, - viewName: string, -): string { - const encodedView = viewName; - if (entryType === 'link') { - return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`; - } - return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`; -} - -function friendlyName(viewName: string): string { - const stripped = viewName.replace(/^x:/, ''); - const parts = stripped.split('/'); - return parts[parts.length - 1]; -} - -interface GlobalSearchProps { - onAfterSelect?: () => void; - autoFocus?: boolean; -} - -export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const GROUP_LABELS: Record = { - link: t('globalSearch.pages', 'Pages'), - form: t('globalSearch.formSections', 'Form Sections'), - field: t('globalSearch.fields', 'Fields'), - }; - const [query, setQuery] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [activeIndex, setActiveIndex] = useState(-1); - const timerRef = useRef | null>(null); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const schema = useSchemaStore((s) => s.schema); - const searchIndex = useSchemaStore((s) => s.searchIndex); - const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); - - const handleQueryChange = useCallback((value: string) => { - setQuery(value); - setActiveIndex(-1); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setDebouncedQuery(value), 300); - }, []); - - useEffect(() => { - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, []); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const results = useMemo(() => { - if (!debouncedQuery.trim() || !schema) return []; - - const tokens = debouncedQuery - .toLowerCase() - .split(/\s+/) - .filter((s) => s.length > 0); - if (tokens.length === 0) return []; - - const filtered = searchIndex.filter((entry) => { - const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase(); - for (const token of tokens) { - if (!haystack.includes(token)) return false; - } - const resolved = resolveObject(schema, entry.viewName); - if (!resolved) return false; - return hasObjectPermission(resolved.permissionPrefix, 'Get'); - }); - - filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]); - return filtered.slice(0, MAX_RESULTS); - }, [debouncedQuery, searchIndex, schema, hasObjectPermission]); - - const groups = useMemo(() => { - const map = new Map(); - for (const entry of results) { - const arr = map.get(entry.type); - if (arr) arr.push(entry); - else map.set(entry.type, [entry]); - } - return map; - }, [results]); - - const handleSelect = useCallback( - (entry: SearchIndexEntry) => { - if (!schema) return; - const objectKind = getObjectKind(schema, entry.viewName); - const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName); - setDropdownOpen(false); - setQuery(''); - setDebouncedQuery(''); - navigate(path); - onAfterSelect?.(); - }, - [schema, navigate, onAfterSelect], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (!dropdownOpen || results.length === 0) return; - if (e.key === 'ArrowDown') { - e.preventDefault(); - setActiveIndex((i) => (i + 1) % results.length); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setActiveIndex((i) => (i - 1 + results.length) % results.length); - } else if (e.key === 'Enter' && activeIndex >= 0) { - e.preventDefault(); - handleSelect(results[activeIndex]); - } else if (e.key === 'Escape') { - setDropdownOpen(false); - } - }, - [dropdownOpen, results, activeIndex, handleSelect], - ); - - const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0; - - return ( -
- - { - handleQueryChange(e.target.value); - setDropdownOpen(true); - }} - onFocus={() => { - if (query.trim()) setDropdownOpen(true); - }} - onKeyDown={handleKeyDown} - className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - - {showDropdown && ( -
- {results.length === 0 ? ( -
- {t('globalSearch.noResults', 'No results found.')} -
- ) : ( -
- {Array.from(groups.entries()).map(([type, entries]) => ( -
-
{GROUP_LABELS[type]}
- {entries.map((entry) => { - const flatIdx = results.indexOf(entry); - const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; - const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); - - return ( - - ); - })} -
- ))} -
- )} -
- )} -
- ); -} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index a031f14..67e1405 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next'; import * as LucideIcons from 'lucide-react'; const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons; import { Button } from '@/components/ui/button'; -import { GlobalSearch } from '@/components/common/GlobalSearch'; -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { CommandPalette } from '@/components/common/CommandPalette'; import { DropdownMenu, DropdownMenuContent, @@ -27,7 +26,7 @@ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleL import { useUIStore } from '@/stores/uiStore'; import { useAuthStore } from '@/stores/authStore'; import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -55,7 +54,20 @@ export function TopBar() { const hasPermission = useAccountStore((s) => s.hasPermission); const schema = useSchemaStore((s) => s.schema); const [upsellOpen, setUpsellOpen] = useState(false); - const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const [paletteOpen, setPaletteOpen] = useState(false); + + useEffect(() => { + function handleGlobalKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setPaletteOpen((open) => !open); + } + } + document.addEventListener('keydown', handleGlobalKeyDown); + return () => document.removeEventListener('keydown', handleGlobalKeyDown); + }, []); + + const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); const navigableLayouts = schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) @@ -81,7 +93,19 @@ export function TopBar() {
- +
@@ -91,18 +115,13 @@ export function TopBar() { variant="ghost" size="icon" className="md:hidden" - onClick={() => setMobileSearchOpen(true)} + onClick={() => setPaletteOpen(true)} aria-label={t('search', 'Search')} > - - - {t('search', 'Search')} - setMobileSearchOpen(false)} /> - - +
From 8fde771ff2928d4a06d9b56e2dd0bd5b745e828b Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 06:48:34 +0200 Subject: [PATCH 07/89] Show switch state with green when on and red when off --- src/components/ui/switch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index ded3fe9..eca5c77 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -15,7 +15,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( Date: Wed, 29 Jul 2026 06:48:41 +0200 Subject: [PATCH 08/89] Show WebUI version and update action in the Web Applications list --- src/components/lists/DynamicList.tsx | 41 ++++-- src/features/webapps/WebAppVersionCell.tsx | 158 +++++++++++++++++++++ src/i18n/en.json | 12 ++ src/lib/versionCompare.test.ts | 47 ++++++ src/lib/versionCompare.ts | 29 ++++ 5 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 src/features/webapps/WebAppVersionCell.tsx create mode 100644 src/lib/versionCompare.test.ts create mode 100644 src/lib/versionCompare.ts diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index fe97119..4772b10 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -28,6 +28,7 @@ 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, @@ -354,6 +355,16 @@ 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]); + const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(null); const [loading, setLoading] = useState(false); @@ -420,7 +431,7 @@ export function DynamicList({ viewName }: DynamicListProps) { try { const accountId = getAccountId(obj.objectName); - const properties = ['id', ...list.columns.map((c) => c.name)]; + const properties = ['id', ...list.columns.map((c) => c.name), ...(isAppList ? ['resourceUrl'] : [])]; const filter = buildFilter(); const sortArr = buildSort(); @@ -470,7 +481,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setLoading(false); } }, - [resolved, schema, buildFilter, buildSort, t], + [resolved, schema, buildFilter, buildSort, isAppList, t], ); useEffect(() => { @@ -1228,7 +1239,7 @@ export function DynamicList({ viewName }: DynamicListProps) { /> )} - {list.columns.map((col) => ( + {displayColumns.map((col) => (
{col.label} @@ -1247,7 +1258,7 @@ export function DynamicList({ viewName }: DynamicListProps) { {loading && items.length === 0 ? ( @@ -1256,7 +1267,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : items.length === 0 ? ( {t('list.noResults', 'No results found')} @@ -1280,15 +1291,19 @@ export function DynamicList({ viewName }: DynamicListProps) { /> )} - {list.columns.map((col) => ( + {displayColumns.map((col) => ( - {renderCellValue( - item[col.name], - fields[col.name], - col.name, - schema!, - resolved.obj.objectName, - getDisplayName, + {col.name === 'x:webuiVersion' ? ( + + ) : ( + renderCellValue( + item[col.name], + fields[col.name], + col.name, + schema!, + resolved.obj.objectName, + getDisplayName, + ) )} ))} diff --git a/src/features/webapps/WebAppVersionCell.tsx b/src/features/webapps/WebAppVersionCell.tsx new file mode 100644 index 0000000..9bb86f5 --- /dev/null +++ b/src/features/webapps/WebAppVersionCell.tsx @@ -0,0 +1,158 @@ +/* + * 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 d5618d1..9e45cce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -371,5 +371,17 @@ "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 new file mode 100644 index 0000000..721805e --- /dev/null +++ b/src/lib/versionCompare.test.ts @@ -0,0 +1,47 @@ +/* + * 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 new file mode 100644 index 0000000..ba24500 --- /dev/null +++ b/src/lib/versionCompare.ts @@ -0,0 +1,29 @@ +/* + * 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; +} From c15cb22be49780382803a7caab0cf7a4eb2902e2 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 07:20:43 +0200 Subject: [PATCH 09/89] Adopt the shared ScrollArea for app-wide scrolling --- src/components/bootstrap/BootstrapWizard.tsx | 11 +++++---- src/components/forms/FieldWidget.tsx | 7 ++++-- src/components/layout/Sidebar.tsx | 7 +++--- src/components/ui/command.tsx | 9 ++++---- src/components/ui/scroll-area.tsx | 13 ++++++++--- src/pages/AdminPanel.tsx | 24 ++++++++++++-------- 6 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/components/bootstrap/BootstrapWizard.tsx b/src/components/bootstrap/BootstrapWizard.tsx index 4674e0e..02724d2 100644 --- a/src/components/bootstrap/BootstrapWizard.tsx +++ b/src/components/bootstrap/BootstrapWizard.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -403,13 +404,15 @@ export function BootstrapWizard() { function WizardShell({ children }: { children: React.ReactNode }) { return ( -
+
-
-
{children}
-
+ +
+
{children}
+
+
); } diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 445d1fb..5ba3b52 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -1831,7 +1832,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems className="h-8" />
-
+ +
{filtered.length === 0 && (

{t('field.noMatches', 'No matches')}

)} @@ -1859,7 +1861,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
))} -
+
+ diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b6b4af5..2e33fb5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useUIStore } from '@/stores/uiStore'; import { useAccountStore } from '@/stores/accountStore'; @@ -342,8 +343,8 @@ export function Sidebar() { onClick={() => setSidebarOpen(false)} />