From 03e951af189287fbc954c8c207b996236bd4fa54 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 20:59:03 +0700 Subject: [PATCH] =?UTF-8?q?feat(app-switcher):=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20=D0=B8?= =?UTF-8?q?=D0=B7=20auth-portal=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CURRENT_APP_ID vps; настройка ссылок только на портале. Co-authored-by: Cursor --- apps/web/src/components/app-switcher.tsx | 4 +- apps/web/src/components/layout/apps-menu.tsx | 30 ++++++--- apps/web/src/hooks/use-app-switcher.ts | 30 +++++++-- apps/web/src/lib/app-switcher-config.test.ts | 58 ++-------------- apps/web/src/lib/app-switcher-config.ts | 67 +++++-------------- apps/web/src/queries/app-switcher.ts | 19 +++++- .../routes/_auth/settings/integrations.tsx | 50 +++++++++++--- docs/ui-design-contract.md | 2 +- packages/shared/src/contracts/app-switcher.ts | 2 + 9 files changed, 126 insertions(+), 136 deletions(-) diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index f467c15..8f76bf2 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -24,7 +24,7 @@ export function AppSwitcher() { const { isMobile } = useSidebar() const { config, isLoading } = useAppSwitcherConfig() const current = getCurrentApp(config) - const CurrentIcon = APP_SWITCHER_ICONS[current.icon] + const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server return ( @@ -61,7 +61,7 @@ export function AppSwitcher() { {isLoading ? 'Загрузка…' : config.menuLabel} {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 if (isCurrent) { diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx index 6a09e26..c6c9f23 100644 --- a/apps/web/src/components/layout/apps-menu.tsx +++ b/apps/web/src/components/layout/apps-menu.tsx @@ -10,15 +10,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@cfdm/ui/components/dropdown-menu' +import { authPortalUrl, isAuthEnabled } from '@/lib/auth' import { APP_SWITCHER_ICONS, CURRENT_APP_ID, } from '@/lib/app-switcher-config' 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() { const { config, isLoading } = useAppSwitcherConfig() + const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps` return ( @@ -45,7 +47,7 @@ export function AppsMenu() {
{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 if (isCurrent) { @@ -79,13 +81,23 @@ export function AppsMenu() { })}
- } - className="justify-center text-sm font-medium" - > - Настроить приложения - + {isAuthEnabled() ? ( + } + className="justify-center text-sm font-medium" + > + Настроить на портале + + ) : ( + } + className="justify-center text-sm font-medium" + > + Интеграции + + )}
diff --git a/apps/web/src/hooks/use-app-switcher.ts b/apps/web/src/hooks/use-app-switcher.ts index ce7a0f1..036717e 100644 --- a/apps/web/src/hooks/use-app-switcher.ts +++ b/apps/web/src/hooks/use-app-switcher.ts @@ -1,19 +1,35 @@ +import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import type { AppSwitcherConfig } from '@cfdm/shared/contracts/app-switcher' import { appSwitcherQueryOptions } from '@/queries/app-switcher' -import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config' - -import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' +import { + DEFAULT_APP_SWITCHER_CONFIG, + getAppUrl as getAppUrlFromConfig, +} from '@/lib/app-switcher-config' +import { getClaims } from '@/lib/auth' export function useAppSwitcherConfig(): { config: AppSwitcherConfig isLoading: boolean } { const { data, isLoading } = useQuery(appSwitcherQueryOptions()) - return { - config: data ?? DEFAULT_APP_SWITCHER_CONFIG, - isLoading, - } + const claims = getClaims() + + 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 { diff --git a/apps/web/src/lib/app-switcher-config.test.ts b/apps/web/src/lib/app-switcher-config.test.ts index dbd580a..740c2d9 100644 --- a/apps/web/src/lib/app-switcher-config.test.ts +++ b/apps/web/src/lib/app-switcher-config.test.ts @@ -1,64 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { CURRENT_APP_ID, DEFAULT_APP_SWITCHER_CONFIG, getCurrentApp, - parseAppSwitcherConfig, } from '@/lib/app-switcher-config' -describe('parseAppSwitcherConfig', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('возвращает дефолты при пустом значении', () => { - expect(parseAppSwitcherConfig()).toEqual(DEFAULT_APP_SWITCHER_CONFIG) - expect(parseAppSwitcherConfig('')).toEqual(DEFAULT_APP_SWITCHER_CONFIG) - }) - - it('дефолты содержат оба приложения', () => { +describe('DEFAULT_APP_SWITCHER_CONFIG', () => { + it('использует portal app ids', () => { const ids = DEFAULT_APP_SWITCHER_CONFIG.apps.map((app) => app.id) - expect(ids).toContain('vps-tracker') - 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() + expect(ids).toEqual(['vps', 'cfdm', 'bgp']) }) }) @@ -66,6 +17,7 @@ describe('getCurrentApp', () => { it('находит текущее приложение по CURRENT_APP_ID', () => { const current = getCurrentApp(DEFAULT_APP_SWITCHER_CONFIG) expect(current.id).toBe(CURRENT_APP_ID) + expect(CURRENT_APP_ID).toBe('vps') expect(current.name).toBe('VPS Tracker') }) }) diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index c0dd8fc..f33959c 100644 --- a/apps/web/src/lib/app-switcher-config.ts +++ b/apps/web/src/lib/app-switcher-config.ts @@ -6,15 +6,17 @@ import { ServerIcon, type LucideIcon, } 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 - -export const APP_SWITCHER_ICONS: Record = { +export const APP_SWITCHER_ICONS: Record< + 'server' | 'cloud' | 'globe' | 'dashboard' | 'chart', + LucideIcon +> = { server: ServerIcon, cloud: CloudIcon, globe: GlobeIcon, @@ -22,80 +24,43 @@ export const APP_SWITCHER_ICONS: Record = { chart: ChartBarIcon, } -const appSwitcherEntrySchema = z.object({ - 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 -export type AppSwitcherConfig = z.infer - +/** Offline fallback when auth-portal is unreachable */ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = { menuLabel: 'Приложения', apps: [ { - id: 'vps-tracker', + id: 'vps', name: 'VPS Tracker', subtitle: 'Учёт виртуальных серверов', - url: 'http://192.168.100.67:3001', + url: 'https://vps.shnt.top', icon: 'server', - shortcut: '⌘1', }, { id: 'cfdm', name: 'CF Domain Manager', subtitle: 'Управление доменами', - url: 'http://192.168.100.67:6363', + url: 'https://cfdm.shnt.top', icon: 'cloud', - shortcut: '⌘2', }, { - id: 'evobgp', + id: 'bgp', name: 'EvoBGP', subtitle: 'BGP маршрутизация', - url: 'http://192.168.100.67:3000', + url: 'https://bgp.shnt.top', 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( appId: string, - config: AppSwitcherConfig = getAppSwitcherConfig(), + config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG, ): string | undefined { return config.apps.find((app) => app.id === appId)?.url } export function getCurrentApp( - config: AppSwitcherConfig = getAppSwitcherConfig(), + config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG, ): AppSwitcherEntry { return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]! } diff --git a/apps/web/src/queries/app-switcher.ts b/apps/web/src/queries/app-switcher.ts index b62f680..ca65522 100644 --- a/apps/web/src/queries/app-switcher.ts +++ b/apps/web/src/queries/app-switcher.ts @@ -1,15 +1,28 @@ import { queryOptions } from '@tanstack/react-query' 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' -export const appSwitcherQueryKey = ['app-switcher'] as const +export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const + +async function fetchPortalAppSwitcher(): Promise { + 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() { return queryOptions({ queryKey: appSwitcherQueryKey, - queryFn: () => api.get('/settings/app-switcher'), + queryFn: fetchPortalAppSwitcher, staleTime: 60_000, placeholderData: DEFAULT_APP_SWITCHER_CONFIG, + retry: 1, }) } diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx index 3e2cfba..dcb9583 100644 --- a/apps/web/src/routes/_auth/settings/integrations.tsx +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -1,16 +1,24 @@ import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ExternalLinkIcon } from 'lucide-react' import { toast } from 'sonner' import { snapshotQueryOptions } from '@/queries/snapshot' -import { appSwitcherQueryKey } from '@/queries/app-switcher' import { api } from '@/lib/api-client' +import { authPortalUrl, isAuthEnabled } from '@/lib/auth' import { QueryState } from '@/components/query-state' import { SectionCardsSkeleton } from '@/components/skeletons' -import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor' import { CfdmIntegrationCard } from '@/components/integrations/cfdm-integration-card' 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')({ component: SettingsIntegrationsPage, @@ -20,13 +28,14 @@ function SettingsIntegrationsPage() { const queryClient = useQueryClient() const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const current = snapshot?.settings?.[0] as Settings | undefined + const { config: appSwitcher } = useAppSwitcherConfig() + const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps` const saveMut = useMutation({ - mutationFn: (patch: Partial & { appSwitcher?: Settings['appSwitcher'] }) => + mutationFn: (patch: Partial) => api.update('settings', current?.id ?? 'settings-main', patch), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) - await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey }) toast.success('Настройки интеграции сохранены') }, onError: () => toast.error('Не удалось сохранить'), @@ -43,11 +52,32 @@ function SettingsIntegrationsPage() { > {() => (
- saveMut.mutate({ appSwitcher })} - /> + + + App Switcher + + {appSwitcher.apps.length} приложений · меню «{appSwitcher.menuLabel}». Ссылки + настраиваются на auth-portal. + + + +

+ Локальный редактор отключён. Source of truth — портал. +

+ {isAuthEnabled() ? ( + + ) : null} +
+