feat(app-switcher): читать конфиг из auth-portal вместо локального editor
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m58s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s

CURRENT_APP_ID cfdm; настройка ссылок только на портале.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 20:59:03 +07:00
co-authored by Cursor
parent f1767b8a1f
commit 9d8c2aff86
9 changed files with 118 additions and 155 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ export function AppSwitcher() {
const { isMobile } = useSidebar() const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig() const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config) const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
return ( return (
<SidebarMenu> <SidebarMenu>
@@ -61,7 +61,7 @@ export function AppSwitcher() {
{isLoading ? 'Загрузка…' : config.menuLabel} {isLoading ? 'Загрузка…' : config.menuLabel}
</div> </div>
{config.apps.map((app) => { {config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) { if (isCurrent) {
+21 -9
View File
@@ -10,15 +10,17 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { import {
APP_SWITCHER_ICONS, APP_SWITCHER_ICONS,
CURRENT_APP_ID, CURRENT_APP_ID,
} from '@/lib/app-switcher-config' } from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to CFDM App Switcher. */ /** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
export function AppsMenu() { export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig() const { config, isLoading } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -45,7 +47,7 @@ export function AppsMenu() {
</DropdownMenuLabel> </DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1"> <div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => { {config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) { if (isCurrent) {
@@ -79,13 +81,23 @@ export function AppsMenu() {
})} })}
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem {isAuthEnabled() ? (
nativeButton={false} <DropdownMenuItem
render={<Link to="/settings/integrations" />} nativeButton={false}
className="justify-center text-sm font-medium" render={<a href={portalAppsUrl} />}
> className="justify-center text-sm font-medium"
Настроить приложения >
</DropdownMenuItem> Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/integrations" />}
className="justify-center text-sm font-medium"
>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
+23 -7
View File
@@ -1,19 +1,35 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cfdm/shared' import type { AppSwitcherConfig } from '@cfdm/shared'
import { appSwitcherQueryOptions } from '@/queries/app-switcher' import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config' import {
DEFAULT_APP_SWITCHER_CONFIG,
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' getAppUrl as getAppUrlFromConfig,
} from '@/lib/app-switcher-config'
import { getClaims } from '@/lib/auth'
export function useAppSwitcherConfig(): { export function useAppSwitcherConfig(): {
config: AppSwitcherConfig config: AppSwitcherConfig
isLoading: boolean isLoading: boolean
} { } {
const { data, isLoading } = useQuery(appSwitcherQueryOptions()) const { data, isLoading } = useQuery(appSwitcherQueryOptions())
return { const claims = getClaims()
config: data ?? DEFAULT_APP_SWITCHER_CONFIG,
isLoading, const config = useMemo(() => {
} const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
const apps = raw.apps.filter((a) => (a as { enabled?: boolean }).enabled !== false)
const allowed = claims?.apps
if (!allowed?.length) {
return { ...raw, apps }
}
const set = new Set(allowed)
return {
...raw,
apps: apps.filter((a) => set.has(a.id)),
}
}, [data, claims?.apps])
return { config, isLoading }
} }
export function useAppUrl(appId: string): string | undefined { export function useAppUrl(appId: string): string | undefined {
+5 -53
View File
@@ -1,64 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
CURRENT_APP_ID, CURRENT_APP_ID,
DEFAULT_APP_SWITCHER_CONFIG, DEFAULT_APP_SWITCHER_CONFIG,
getCurrentApp, getCurrentApp,
parseAppSwitcherConfig,
} from '@/lib/app-switcher-config' } from '@/lib/app-switcher-config'
describe('parseAppSwitcherConfig', () => { describe('DEFAULT_APP_SWITCHER_CONFIG', () => {
afterEach(() => { it('использует portal app ids', () => {
vi.restoreAllMocks()
})
it('возвращает дефолты при пустом значении', () => {
expect(parseAppSwitcherConfig()).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
expect(parseAppSwitcherConfig('')).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
})
it('дефолты содержат оба приложения', () => {
const ids = DEFAULT_APP_SWITCHER_CONFIG.apps.map((app) => app.id) const ids = DEFAULT_APP_SWITCHER_CONFIG.apps.map((app) => app.id)
expect(ids).toContain('vps-tracker') expect(ids).toEqual(['vps', 'cfdm', 'bgp'])
expect(ids).toContain('cfdm')
})
it('парсит override из JSON', () => {
const raw = JSON.stringify({
menuLabel: 'Сервисы',
apps: [
{
id: 'vps-tracker',
name: 'VPS',
url: 'http://localhost:5173',
icon: 'server',
},
{
id: 'cfdm',
name: 'CFDM',
url: 'http://localhost:5174',
icon: 'cloud',
},
{
id: 'grafana',
name: 'Grafana',
url: 'https://grafana.example.com',
icon: 'chart',
},
],
})
const config = parseAppSwitcherConfig(raw)
expect(config.menuLabel).toBe('Сервисы')
expect(config.apps).toHaveLength(3)
expect(config.apps[2]?.name).toBe('Grafana')
})
it('при невалидном JSON возвращает дефолты и пишет предупреждение', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseAppSwitcherConfig('{invalid')).toEqual(DEFAULT_APP_SWITCHER_CONFIG)
expect(warnSpy).toHaveBeenCalled()
}) })
}) })
@@ -66,6 +17,7 @@ describe('getCurrentApp', () => {
it('находит текущее приложение по CURRENT_APP_ID', () => { it('находит текущее приложение по CURRENT_APP_ID', () => {
const current = getCurrentApp(DEFAULT_APP_SWITCHER_CONFIG) const current = getCurrentApp(DEFAULT_APP_SWITCHER_CONFIG)
expect(current.id).toBe(CURRENT_APP_ID) expect(current.id).toBe(CURRENT_APP_ID)
expect(CURRENT_APP_ID).toBe('cfdm')
expect(current.name).toBe('CF Domain Manager') expect(current.name).toBe('CF Domain Manager')
}) })
}) })
+15 -50
View File
@@ -6,15 +6,17 @@ import {
ServerIcon, ServerIcon,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { z } from 'zod' import type { AppSwitcherConfig, AppSwitcherEntry } from '@cfdm/shared'
/** JWT / portal app id for this product */
export const CURRENT_APP_ID = 'cfdm' export const CURRENT_APP_ID = 'cfdm'
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart']) export type AppSwitcherIconName = keyof typeof APP_SWITCHER_ICONS
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema> export const APP_SWITCHER_ICONS: Record<
'server' | 'cloud' | 'globe' | 'dashboard' | 'chart',
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = { LucideIcon
> = {
server: ServerIcon, server: ServerIcon,
cloud: CloudIcon, cloud: CloudIcon,
globe: GlobeIcon, globe: GlobeIcon,
@@ -22,80 +24,43 @@ export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
chart: ChartBarIcon, chart: ChartBarIcon,
} }
const appSwitcherEntrySchema = z.object({ /** Offline fallback when auth-portal is unreachable */
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string(),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = { export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения', menuLabel: 'Приложения',
apps: [ apps: [
{ {
id: 'vps-tracker', id: 'vps',
name: 'VPS Tracker', name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов', subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001', url: 'https://vps.shnt.top',
icon: 'server', icon: 'server',
shortcut: '⌘1',
}, },
{ {
id: 'cfdm', id: 'cfdm',
name: 'CF Domain Manager', name: 'CF Domain Manager',
subtitle: 'Управление доменами', subtitle: 'Управление доменами',
url: 'http://192.168.100.67:6363', url: 'https://cfdm.shnt.top',
icon: 'cloud', icon: 'cloud',
shortcut: '⌘2',
}, },
{ {
id: 'evobgp', id: 'bgp',
name: 'EvoBGP', name: 'EvoBGP',
subtitle: 'BGP маршрутизация', subtitle: 'BGP маршрутизация',
url: 'http://192.168.100.67:3000', url: 'https://bgp.shnt.top',
icon: 'globe', icon: 'globe',
shortcut: '⌘3',
}, },
], ],
} }
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
if (!raw?.trim()) {
return DEFAULT_APP_SWITCHER_CONFIG
}
try {
const parsed = JSON.parse(raw) as unknown
return appSwitcherConfigSchema.parse(parsed)
} catch (error) {
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
return DEFAULT_APP_SWITCHER_CONFIG
}
}
export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getAppUrl( export function getAppUrl(
appId: string, appId: string,
config: AppSwitcherConfig = getAppSwitcherConfig(), config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): string | undefined { ): string | undefined {
return config.apps.find((app) => app.id === appId)?.url return config.apps.find((app) => app.id === appId)?.url
} }
export function getCurrentApp( export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(), config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): AppSwitcherEntry { ): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]! return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
} }
+16 -3
View File
@@ -1,15 +1,28 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@cfdm/shared' import type { AppSwitcherConfig } from '@cfdm/shared'
import { api } from '@/lib/api-client' import { ensureAuthConfig } from '@/lib/auth'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher'] as const export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
const { portalUrl } = await ensureAuthConfig()
const base = portalUrl.replace(/\/$/, '')
const res = await fetch(`${base}/api/v1/app-switcher`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(`app-switcher ${res.status}`)
}
return (await res.json()) as AppSwitcherConfig
}
export function appSwitcherQueryOptions() { export function appSwitcherQueryOptions() {
return queryOptions({ return queryOptions({
queryKey: appSwitcherQueryKey, queryKey: appSwitcherQueryKey,
queryFn: () => api.get<AppSwitcherConfig>('/api/v1/settings/app-switcher'), queryFn: fetchPortalAppSwitcher,
staleTime: 60_000, staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG, placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
retry: 1,
}) })
} }
@@ -1,19 +1,18 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronDownIcon, LayoutGridIcon, ServerIcon } from 'lucide-react' import { ChevronDownIcon, ExternalLinkIcon, LayoutGridIcon, ServerIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared' import type { AppSettingsPatch } from '@cfdm/shared'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor'
import { import {
VpsTrackerIntegrationCard, VpsTrackerIntegrationCard,
type AppSettingsView, type AppSettingsView,
} from '@/components/integrations/vps-tracker-integration-card' } from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
@@ -41,34 +40,23 @@ import { cn } from '@cfdm/ui/lib/utils'
type SettingsResponse = AppSettingsView & { type SettingsResponse = AppSettingsView & {
id: string id: string
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG
} }
type IntegrationStatus = 'connected' | 'available' | 'warning' type IntegrationStatus = 'connected' | 'available' | 'warning'
const STATUS_META: Record< const STATUS_META: Record<
IntegrationStatus, IntegrationStatus,
{ label: string; variant: 'success-light' | 'secondary' | 'warning-light' } { label: string; variant: 'success' | 'secondary' | 'warning' }
> = { > = {
connected: { label: 'Подключено', variant: 'success-light' }, connected: { label: 'Подключено', variant: 'success' },
available: { label: 'Доступно', variant: 'secondary' }, available: { label: 'Доступно', variant: 'secondary' },
warning: { label: 'Требует настройки', variant: 'warning-light' }, warning: { label: 'Требует настройки', variant: 'warning' },
} }
export const Route = createFileRoute('/_auth/settings/integrations')({ export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage, component: SettingsIntegrationsPage,
}) })
function IntegrationLogo({ children }: { children: ReactNode }) {
return (
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
{children}
</ItemMedia>
</Item>
)
}
function IntegrationRow({ function IntegrationRow({
id, id,
name, name,
@@ -92,10 +80,8 @@ function IntegrationRow({
return ( return (
<Collapsible open={open} onOpenChange={onOpenChange}> <Collapsible open={open} onOpenChange={onOpenChange}>
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4"> <Item className="items-center gap-3 border-0 px-3.5 py-3 sm:px-4">
<ItemMedia className="translate-y-0! self-center!"> <ItemMedia variant="icon">{logo}</ItemMedia>
<IntegrationLogo>{logo}</IntegrationLogo>
</ItemMedia>
<ItemContent className="min-w-0 gap-0"> <ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2"> <ItemTitle className="w-full min-w-0 gap-2">
@@ -138,6 +124,8 @@ function SettingsIntegrationsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [openAppSwitcher, setOpenAppSwitcher] = useState(true) const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
const [openVpsTracker, setOpenVpsTracker] = useState(true) const [openVpsTracker, setOpenVpsTracker] = useState(true)
const { config: appSwitcher } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['app-settings'], queryKey: ['app-settings'],
@@ -149,13 +137,11 @@ function SettingsIntegrationsPage() {
api.patch<SettingsResponse>('/api/v1/settings', patch), api.patch<SettingsResponse>('/api/v1/settings', patch),
onSuccess: async () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['app-settings'] }) await queryClient.invalidateQueries({ queryKey: ['app-settings'] })
await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
toast.success('Настройки сохранены') toast.success('Настройки сохранены')
}, },
onError: () => toast.error('Не удалось сохранить'), onError: () => toast.error('Не удалось сохранить'),
}) })
const appSwitcher = data?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG
const appSwitcherStatus: IntegrationStatus = const appSwitcherStatus: IntegrationStatus =
appSwitcher.apps.length > 0 ? 'connected' : 'available' appSwitcher.apps.length > 0 ? 'connected' : 'available'
@@ -188,7 +174,7 @@ function SettingsIntegrationsPage() {
<FrameHeader className="sr-only"> <FrameHeader className="sr-only">
<FrameTitle>App Switcher</FrameTitle> <FrameTitle>App Switcher</FrameTitle>
<FrameDescription> <FrameDescription>
Быстрый переход между связанными приложениями Ссылки приложений настраиваются на auth-portal
</FrameDescription> </FrameDescription>
</FrameHeader> </FrameHeader>
<FramePanel className="p-0!"> <FramePanel className="p-0!">
@@ -202,11 +188,28 @@ function SettingsIntegrationsPage() {
open={openAppSwitcher} open={openAppSwitcher}
onOpenChange={setOpenAppSwitcher} onOpenChange={setOpenAppSwitcher}
> >
<AppSwitcherEditor <div className="flex flex-col gap-3">
defaultValues={appSwitcher} <p className="text-muted-foreground text-sm">
isSaving={saveMut.isPending} Список и URL сервисов хранятся на auth-portal. Локальный редактор
onSave={(next) => saveMut.mutate({ appSwitcher: next })} отключён.
/> </p>
{isAuthEnabled() ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
render={<a href={portalAppsUrl} />}
>
Открыть на портале
<ExternalLinkIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : (
<p className="text-muted-foreground text-xs">
Включите auth-portal, чтобы редактировать ссылки.
</p>
)}
</div>
</IntegrationRow> </IntegrationRow>
</ItemGroup> </ItemGroup>
</FramePanel> </FramePanel>
+1 -1
View File
@@ -68,7 +68,7 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
NavUser (footer): avatar + name/email из portal JWT; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1). NavUser (footer): avatar + name/email из portal JWT; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON. App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp`. Admin: portal `/admin/apps`. Локальный editor убран.
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12). QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
+2
View File
@@ -14,6 +14,8 @@ export const appSwitcherEntrySchema = z.object({
subtitle: z.string().optional(), subtitle: z.string().optional(),
url: z.string().url(), url: z.string().url(),
icon: appSwitcherIconSchema.default("server"), icon: appSwitcherIconSchema.default("server"),
enabled: z.boolean().optional(),
sort: z.number().optional(),
shortcut: z.string().optional(), shortcut: z.string().optional(),
}); });