feat(app-switcher): читать конфиг из auth-portal вместо локального editor
Docker / build (push) Failing after 18s
Docker / build (push) Failing after 18s
CURRENT_APP_ID vps; настройка ссылок только на портале. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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. @see https://reui.io/preview/base/app-shell-12 */
|
/** 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>
|
||||||
|
|||||||
@@ -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/contracts/app-switcher'
|
import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher'
|
||||||
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 {
|
||||||
|
|||||||
@@ -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('vps')
|
||||||
expect(current.name).toBe('VPS Tracker')
|
expect(current.name).toBe('VPS Tracker')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/contracts/app-switcher'
|
||||||
|
|
||||||
export const CURRENT_APP_ID = 'vps-tracker'
|
/** JWT / portal app id for this product */
|
||||||
|
export const CURRENT_APP_ID = 'vps'
|
||||||
|
|
||||||
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]!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher'
|
import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher'
|
||||||
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>('/settings/app-switcher'),
|
queryFn: fetchPortalAppSwitcher,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
retry: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
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 { ExternalLinkIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { appSwitcherQueryKey } from '@/queries/app-switcher'
|
|
||||||
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 { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor'
|
|
||||||
import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card'
|
import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card'
|
||||||
import type { Settings } from '@/types/entities'
|
import type { Settings } from '@/types/entities'
|
||||||
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings/integrations')({
|
export const Route = createFileRoute('/_auth/settings/integrations')({
|
||||||
component: SettingsIntegrationsPage,
|
component: SettingsIntegrationsPage,
|
||||||
@@ -20,13 +28,14 @@ function SettingsIntegrationsPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const current = snapshot?.settings?.[0] as Settings | undefined
|
const current = snapshot?.settings?.[0] as Settings | undefined
|
||||||
|
const { config: appSwitcher } = useAppSwitcherConfig()
|
||||||
|
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (patch: Partial<Settings> & { appSwitcher?: Settings['appSwitcher'] }) =>
|
mutationFn: (patch: Partial<Settings>) =>
|
||||||
api.update<Settings>('settings', current?.id ?? 'settings-main', patch),
|
api.update<Settings>('settings', current?.id ?? 'settings-main', patch),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
|
await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey })
|
||||||
await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
|
|
||||||
toast.success('Настройки интеграции сохранены')
|
toast.success('Настройки интеграции сохранены')
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Не удалось сохранить'),
|
onError: () => toast.error('Не удалось сохранить'),
|
||||||
@@ -43,11 +52,32 @@ function SettingsIntegrationsPage() {
|
|||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<AppSwitcherEditor
|
<Frame>
|
||||||
defaultValues={current?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
|
<FrameHeader>
|
||||||
isSaving={saveMut.isPending}
|
<FrameTitle>App Switcher</FrameTitle>
|
||||||
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
|
<FrameDescription>
|
||||||
/>
|
{appSwitcher.apps.length} приложений · меню «{appSwitcher.menuLabel}». Ссылки
|
||||||
|
настраиваются на auth-portal.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Локальный редактор отключён. Source of truth — портал.
|
||||||
|
</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>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
<CfdmIntegrationCard
|
<CfdmIntegrationCard
|
||||||
settings={current}
|
settings={current}
|
||||||
isSaving={saveMut.isPending}
|
isSaving={saveMut.isPending}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick
|
|||||||
|
|
||||||
Запрещено в chrome: `SidebarRail`, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`, ModeToggle в header (тема только в NavUser).
|
Запрещено в chrome: `SidebarRail`, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`, ModeToggle в header (тема только в NavUser).
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const appSwitcherEntrySchema = z.object({
|
|||||||
subtitle: z.string().optional(),
|
subtitle: z.string().optional(),
|
||||||
url: z.string().url('Невалидный URL'),
|
url: z.string().url('Невалидный 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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user