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 3b1387e..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, wired to CFDM App Switcher. */ +/** 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 0bae667..830c91d 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' 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 be40a74..8cfc9b1 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('cfdm') expect(current.name).toBe('CF Domain Manager') }) }) diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index 90ac587..f9b25a3 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' +/** JWT / portal app id for this product */ 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 - -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 ec3b1f0..06a6acc 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' -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('/api/v1/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 9dd2ac6..a77bec0 100644 --- a/apps/web/src/routes/_auth/settings/integrations.tsx +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -1,19 +1,18 @@ import { useState, type ReactNode } from 'react' import { createFileRoute } from '@tanstack/react-router' 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 type { AppSettingsPatch } from '@cfdm/shared' import { api } from '@/lib/api-client' +import { authPortalUrl, isAuthEnabled } from '@/lib/auth' import { QueryState } from '@/components/query-state' -import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor' import { VpsTrackerIntegrationCard, type AppSettingsView, } from '@/components/integrations/vps-tracker-integration-card' -import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' -import { appSwitcherQueryKey } from '@/queries/app-switcher' +import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' import { Badge } from '@/components/reui/badge' import { Frame, @@ -41,34 +40,23 @@ import { cn } from '@cfdm/ui/lib/utils' type SettingsResponse = AppSettingsView & { id: string - appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG } type IntegrationStatus = 'connected' | 'available' | 'warning' const STATUS_META: Record< 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' }, - warning: { label: 'Требует настройки', variant: 'warning-light' }, + warning: { label: 'Требует настройки', variant: 'warning' }, } export const Route = createFileRoute('/_auth/settings/integrations')({ component: SettingsIntegrationsPage, }) -function IntegrationLogo({ children }: { children: ReactNode }) { - return ( - - - {children} - - - ) -} - function IntegrationRow({ id, name, @@ -92,10 +80,8 @@ function IntegrationRow({ return ( - - - {logo} - + + {logo} @@ -138,6 +124,8 @@ function SettingsIntegrationsPage() { const queryClient = useQueryClient() const [openAppSwitcher, setOpenAppSwitcher] = useState(true) const [openVpsTracker, setOpenVpsTracker] = useState(true) + const { config: appSwitcher } = useAppSwitcherConfig() + const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps` const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ['app-settings'], @@ -149,13 +137,11 @@ function SettingsIntegrationsPage() { api.patch('/api/v1/settings', patch), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['app-settings'] }) - await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey }) toast.success('Настройки сохранены') }, onError: () => toast.error('Не удалось сохранить'), }) - const appSwitcher = data?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG const appSwitcherStatus: IntegrationStatus = appSwitcher.apps.length > 0 ? 'connected' : 'available' @@ -188,7 +174,7 @@ function SettingsIntegrationsPage() { App Switcher - Быстрый переход между связанными приложениями + Ссылки приложений настраиваются на auth-portal @@ -202,11 +188,28 @@ function SettingsIntegrationsPage() { open={openAppSwitcher} onOpenChange={setOpenAppSwitcher} > - saveMut.mutate({ appSwitcher: next })} - /> +
+

+ Список и URL сервисов хранятся на auth-portal. Локальный редактор + отключён. +

+ {isAuthEnabled() ? ( + + ) : ( +

+ Включите auth-portal, чтобы редактировать ссылки. +

+ )} +
diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index 9380ab6..2f82796 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -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). -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). diff --git a/packages/shared/src/app-switcher.ts b/packages/shared/src/app-switcher.ts index 80e101b..e0cdff8 100644 --- a/packages/shared/src/app-switcher.ts +++ b/packages/shared/src/app-switcher.ts @@ -14,6 +14,8 @@ export const appSwitcherEntrySchema = z.object({ subtitle: z.string().optional(), url: z.string().url(), icon: appSwitcherIconSchema.default("server"), + enabled: z.boolean().optional(), + sort: z.number().optional(), shortcut: z.string().optional(), });