From 0fa4b7adeacd7909cbd70d068f124533c94dad2f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 18 Jul 2026 20:59:01 +0700 Subject: [PATCH] =?UTF-8?q?feat(app-switcher):=20=D1=86=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20portal=20settin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store. Co-authored-by: Cursor --- apps/api/src/routes/admin.ts | 17 ++ apps/api/src/routes/auth.ts | 27 +-- apps/api/test/app-switcher.test.ts | 94 +++++++++++ apps/web/src/components/app-sidebar.tsx | 14 +- apps/web/src/components/app-switcher.tsx | 34 ++-- .../reui-kit/app-switcher-admin-editor.tsx | 159 ++++++++++++++++++ apps/web/src/queries/app-switcher.ts | 21 +++ apps/web/src/routeTree.gen.ts | 35 +++- apps/web/src/routes/_auth.admin.apps.tsx | 89 ++++++++++ apps/web/src/routes/_auth.apps.tsx | 30 ++-- docs/integrate-cfdm.md | 8 + docs/integrate-vps-tracker.md | 10 +- docs/ui-design-contract.md | 4 +- packages/db/src/index.ts | 7 + packages/db/src/schema/index.ts | 7 + packages/db/src/settings.ts | 54 ++++++ packages/shared/src/contracts/app-switcher.ts | 96 +++++++++++ packages/shared/src/index.ts | 2 + 18 files changed, 662 insertions(+), 46 deletions(-) create mode 100644 apps/api/test/app-switcher.test.ts create mode 100644 apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx create mode 100644 apps/web/src/queries/app-switcher.ts create mode 100644 apps/web/src/routes/_auth.admin.apps.tsx create mode 100644 packages/db/src/settings.ts create mode 100644 packages/shared/src/contracts/app-switcher.ts diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index b82e08b..0307d15 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -3,17 +3,20 @@ import { hash } from '@node-rs/argon2' import { createUser, deleteUser, + getAppSwitcherConfig, getUserApps, getUserByEmail, getUserById, getUserPermissions, listUsers, + setAppSwitcherConfig, setUserAccess, updateUser, } from '@authportal/db' import { APP_IDS, allPermissionKeys, + appSwitcherConfigSchema, createUserRequestSchema, patchUserRequestSchema, putUserAccessRequestSchema, @@ -205,4 +208,18 @@ export async function adminRoutes(app: FastifyInstance): Promise { return mapUser(app.db, getUserById(app.db, request.params.id)!) }, ) + + app.get('/api/v1/admin/app-switcher', async () => + getAppSwitcherConfig(app.db), + ) + + app.put('/api/v1/admin/app-switcher', async (request, reply) => { + const parsed = appSwitcherConfigSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ + error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' }, + }) + } + return setAppSwitcherConfig(app.db, parsed.data) + }) } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index fe97254..4b9d8c2 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,19 +1,20 @@ import type { FastifyInstance } from 'fastify' import { hash, verify } from '@node-rs/argon2' import { randomBytes } from 'node:crypto' +import { + PERMISSION_CATALOG, + appsMetaFromSwitcher, + loginRequestSchema, + type LoginResponse, +} from '@authportal/shared' import { createRefreshSession, + getAppSwitcherConfig, getUserApps, getUserByEmail, getUserPermissions, revokeRefreshSession, } from '@authportal/db' -import { - APPS, - PERMISSION_CATALOG, - loginRequestSchema, - type LoginResponse, -} from '@authportal/shared' import { requireAuth, toMe } from '../plugins/auth-guards.js' const REFRESH_COOKIE = 'refresh_token' @@ -104,6 +105,9 @@ export async function authRoutes(app: FastifyInstance): Promise { return { ok: true } }) + /** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */ + app.get('/api/v1/app-switcher', async () => getAppSwitcherConfig(app.db)) + app.get( '/api/v1/auth/me', { onRequest: requireAuth }, @@ -123,9 +127,12 @@ export async function authRoutes(app: FastifyInstance): Promise { app.get( '/api/v1/catalog', { onRequest: requireAuth }, - async () => ({ - apps: APPS, - permissions: PERMISSION_CATALOG, - }), + async () => { + const switcher = getAppSwitcherConfig(app.db) + return { + apps: appsMetaFromSwitcher(switcher), + permissions: PERMISSION_CATALOG, + } + }, ) } diff --git a/apps/api/test/app-switcher.test.ts b/apps/api/test/app-switcher.test.ts new file mode 100644 index 0000000..bdb289c --- /dev/null +++ b/apps/api/test/app-switcher.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { buildApp } from '../src/app.js' +import { loadConfig } from '../src/config.js' + +describe('app-switcher API', () => { + it('GET /api/v1/app-switcher is public and returns defaults', async () => { + const config = loadConfig({ + ...process.env, + JWT_SECRET: 'test-secret-at-least-8', + ADMIN_PASSWORD: 'admin', + DATABASE_URL: 'sqlite::memory:', + NODE_ENV: 'test', + }) + const app = await buildApp({ config, databaseUrl: 'sqlite::memory:' }) + const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' }) + expect(res.statusCode).toBe(200) + const body = res.json() as { menuLabel: string; apps: { id: string }[] } + expect(body.menuLabel).toBeTruthy() + expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps']) + await app.close() + }) + + it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => { + const config = loadConfig({ + ...process.env, + JWT_SECRET: 'test-secret-at-least-8', + ADMIN_EMAIL: 'admin@test.local', + ADMIN_PASSWORD: 'adminpass', + DATABASE_URL: 'sqlite::memory:', + NODE_ENV: 'test', + }) + const app = await buildApp({ config, databaseUrl: 'sqlite::memory:' }) + + const denied = await app.inject({ + method: 'PUT', + url: '/api/v1/admin/app-switcher', + payload: { menuLabel: 'Apps', apps: [] }, + }) + expect(denied.statusCode).toBe(401) + + const login = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'admin@test.local', password: 'adminpass' }, + }) + expect(login.statusCode).toBe(200) + const token = (login.json() as { access_token: string }).access_token + + const getBefore = await app.inject({ + method: 'GET', + url: '/api/v1/app-switcher', + }) + const before = getBefore.json() as { + menuLabel: string + apps: { + id: string + name: string + url: string + icon: string + enabled: boolean + }[] + } + + const updated = { + menuLabel: 'Сервисы', + apps: before.apps.map((a) => + a.id === 'cfdm' + ? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' } + : a, + ), + } + + const put = await app.inject({ + method: 'PUT', + url: '/api/v1/admin/app-switcher', + headers: { authorization: `Bearer ${token}` }, + payload: updated, + }) + expect(put.statusCode).toBe(200) + expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' }) + + const getAfter = await app.inject({ + method: 'GET', + url: '/api/v1/app-switcher', + }) + const after = getAfter.json() as typeof before + expect(after.menuLabel).toBe('Сервисы') + expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe( + 'https://cfdm.example.test', + ) + + await app.close() + }) +}) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 043c3b4..832d35e 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,6 +1,6 @@ import { Link, useRouterState } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { LayoutGridIcon, UsersIcon } from 'lucide-react' +import { LayoutGridIcon, UsersIcon, AppWindowIcon } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { meQueryOptions } from '@/queries/auth' import { @@ -57,13 +57,23 @@ export function AppSidebar() { } > Пользователи + + } + > + + Ссылки приложений + + diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index c6ecc95..0d9ab43 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -1,4 +1,4 @@ -import { APPS } from '@authportal/shared' +import { useQuery } from '@tanstack/react-query' import { CheckIcon, ChevronsUpDownIcon, @@ -6,7 +6,11 @@ import { CloudIcon, ServerIcon, NetworkIcon, + LayoutDashboardIcon, + ChartColumnIcon, } from 'lucide-react' +import type { AppSwitcherIconName } from '@authportal/shared' +import { appSwitcherQueryOptions } from '@/queries/app-switcher' import { DropdownMenu, DropdownMenuContent, @@ -24,18 +28,24 @@ const PORTAL = { id: 'portal', name: 'Auth Portal', subtitle: 'shnt.top', - url: '/', - icon: KeyRoundIcon, } -const APP_ICONS = { - cfdm: CloudIcon, - vps: ServerIcon, - bgp: NetworkIcon, -} as const +const ICON_MAP: Record< + AppSwitcherIconName, + React.ComponentType<{ className?: string }> +> = { + cloud: CloudIcon, + server: ServerIcon, + globe: NetworkIcon, + dashboard: LayoutDashboardIcon, + chart: ChartColumnIcon, +} export function AppSwitcher() { const { isMobile } = useSidebar() + const { data } = useQuery(appSwitcherQueryOptions) + const menuLabel = data?.menuLabel ?? 'Приложения' + const apps = (data?.apps ?? []).filter((a) => a.enabled !== false) return ( @@ -67,15 +77,15 @@ export function AppSwitcher() { sideOffset={4} >
- Приложения + {menuLabel}
Auth Portal - {APPS.map((app) => { - const Icon = APP_ICONS[app.id] + {apps.map((app) => { + const Icon = ICON_MAP[app.icon] ?? ServerIcon return ( } > - {app.title} + {app.name} ) })} diff --git a/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx b/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx new file mode 100644 index 0000000..5c904d2 --- /dev/null +++ b/apps/web/src/components/reui-kit/app-switcher-admin-editor.tsx @@ -0,0 +1,159 @@ +/** + * Admin editor for portal App Switcher URLs. + * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 + */ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + APP_IDS, + appSwitcherConfigSchema, + type AppId, + type AppSwitcherConfig, + type AppSwitcherIconName, +} from '@authportal/shared' +import { Button } from '@authportal/ui/components/button' +import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field' +import { Input } from '@authportal/ui/components/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@authportal/ui/components/select' +import { Switch } from '@authportal/ui/components/switch' +import { ItemSeparator } from '@authportal/ui/components/item' + +const ICON_OPTIONS: AppSwitcherIconName[] = [ + 'server', + 'cloud', + 'globe', + 'dashboard', + 'chart', +] + +interface AppSwitcherAdminEditorProps { + defaultValues: AppSwitcherConfig + onSave: (values: AppSwitcherConfig) => void + isSaving?: boolean +} + +export function AppSwitcherAdminEditor({ + defaultValues, + onSave, + isSaving, +}: AppSwitcherAdminEditorProps) { + const form = useForm({ + resolver: zodResolver(appSwitcherConfigSchema), + defaultValues, + }) + + useEffect(() => { + form.reset(defaultValues) + }, [defaultValues, form]) + + return ( +
+ void form.handleSubmit((values) => onSave(values))(e) + } + > + + + Заголовок меню + + + + {APP_IDS.map((appId, index) => { + const apps = form.watch('apps') + const appIndex = apps.findIndex((a) => a.id === appId) + if (appIndex < 0) return null + return ( +
+ {index > 0 ? : null} +

+ {appId.toUpperCase()} +

+
+ + Название + + + + URL + + + + + Описание + + + + + Иконка + + + + + form.setValue(`apps.${appIndex}.enabled`, v, { + shouldDirty: true, + }) + } + /> + Включено в switcher + +
+ +
+ ) + })} +
+ + +
+ ) +} diff --git a/apps/web/src/queries/app-switcher.ts b/apps/web/src/queries/app-switcher.ts new file mode 100644 index 0000000..26884a2 --- /dev/null +++ b/apps/web/src/queries/app-switcher.ts @@ -0,0 +1,21 @@ +import { queryOptions } from '@tanstack/react-query' +import type { AppSwitcherConfig } from '@authportal/shared' +import { api } from '@/lib/api-client' + +export const appSwitcherQueryKey = ['app-switcher'] as const +export const adminAppSwitcherQueryKey = ['admin', 'app-switcher'] as const + +export const appSwitcherQueryOptions = queryOptions({ + queryKey: appSwitcherQueryKey, + queryFn: () => api.get('/api/v1/app-switcher'), + staleTime: 60_000, +}) + +export const adminAppSwitcherQueryOptions = queryOptions({ + queryKey: adminAppSwitcherQueryKey, + queryFn: () => api.get('/api/v1/admin/app-switcher'), +}) + +export function putAppSwitcher(config: AppSwitcherConfig) { + return api.put('/api/v1/admin/app-switcher', config) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 28bdbd0..27e7454 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as LogoutRouteImport } from './routes/logout' import { Route as AuthAdminRouteImport } from './routes/_auth.admin' import { Route as AuthAppsRouteImport } from './routes/_auth.apps' import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index' +import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId' const IndexRoute = IndexRouteImport.update({ @@ -46,6 +47,11 @@ const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({ path: '/', getParentRoute: () => AuthAdminRoute, } as any) +const AuthAdminAppsRoute = AuthAdminAppsRouteImport.update({ + id: '/apps', + path: '/apps', + getParentRoute: () => AuthAdminRoute, +} as any) const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ id: '/users/$userId', path: '/users/$userId', @@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { '/logout': typeof LogoutRoute '/admin': typeof AuthAdminRouteWithChildren '/apps': typeof AuthAppsRoute + '/admin/apps': typeof AuthAdminAppsRoute '/admin/': typeof AuthAdminIndexRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } @@ -64,6 +71,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/logout': typeof LogoutRoute '/apps': typeof AuthAppsRoute + '/admin/apps': typeof AuthAdminAppsRoute '/admin': typeof AuthAdminIndexRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } @@ -74,15 +82,28 @@ export interface FileRoutesById { '/logout': typeof LogoutRoute '/_auth/admin': typeof AuthAdminRouteWithChildren '/_auth/apps': typeof AuthAppsRoute + '/_auth/admin/apps': typeof AuthAdminAppsRoute '/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - '/' | '/logout' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId' + | '/' + | '/logout' + | '/admin' + | '/apps' + | '/admin/apps' + | '/admin/' + | '/admin/users/$userId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/logout' | '/apps' | '/admin' | '/admin/users/$userId' + to: + | '/' + | '/logout' + | '/apps' + | '/admin/apps' + | '/admin' + | '/admin/users/$userId' id: | '__root__' | '/' @@ -90,6 +111,7 @@ export interface FileRouteTypes { | '/logout' | '/_auth/admin' | '/_auth/apps' + | '/_auth/admin/apps' | '/_auth/admin/' | '/_auth/admin/users/$userId' fileRoutesById: FileRoutesById @@ -144,6 +166,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAdminIndexRouteImport parentRoute: typeof AuthAdminRoute } + '/_auth/admin/apps': { + id: '/_auth/admin/apps' + path: '/apps' + fullPath: '/admin/apps' + preLoaderRoute: typeof AuthAdminAppsRouteImport + parentRoute: typeof AuthAdminRoute + } '/_auth/admin/users/$userId': { id: '/_auth/admin/users/$userId' path: '/users/$userId' @@ -155,11 +184,13 @@ declare module '@tanstack/react-router' { } interface AuthAdminRouteChildren { + AuthAdminAppsRoute: typeof AuthAdminAppsRoute AuthAdminIndexRoute: typeof AuthAdminIndexRoute AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute } const AuthAdminRouteChildren: AuthAdminRouteChildren = { + AuthAdminAppsRoute: AuthAdminAppsRoute, AuthAdminIndexRoute: AuthAdminIndexRoute, AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute, } diff --git a/apps/web/src/routes/_auth.admin.apps.tsx b/apps/web/src/routes/_auth.admin.apps.tsx new file mode 100644 index 0000000..586e560 --- /dev/null +++ b/apps/web/src/routes/_auth.admin.apps.tsx @@ -0,0 +1,89 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { defaultAppSwitcherConfig } from '@authportal/shared' +import { PageShell } from '@/components/page-shell' +import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { Skeleton } from '@authportal/ui/components/skeleton' +import { ApiError } from '@/lib/api-client' +import { + adminAppSwitcherQueryKey, + adminAppSwitcherQueryOptions, + appSwitcherQueryKey, + putAppSwitcher, +} from '@/queries/app-switcher' +import { catalogQueryKey } from '@/queries/auth' + +export const Route = createFileRoute('/_auth/admin/apps')({ + component: AdminAppsPage, +}) + +function AdminAppsPage() { + const queryClient = useQueryClient() + const { data, isLoading, isError, error } = useQuery( + adminAppSwitcherQueryOptions, + ) + + const saveMutation = useMutation({ + mutationFn: putAppSwitcher, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: adminAppSwitcherQueryKey }) + void queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey }) + void queryClient.invalidateQueries({ queryKey: catalogQueryKey }) + toast.success('Ссылки приложений сохранены') + }, + onError: (err) => { + toast.error( + err instanceof ApiError ? err.message : 'Не удалось сохранить', + ) + }, + }) + + return ( + +
+

Приложения

+

+ URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP) +

+
+ + + + Ссылки сервисов + + Публичный конфиг: GET /api/v1/app-switcher — читают приложения + + + + {isLoading ? ( +
+ + + +
+ ) : isError ? ( +

+ {error instanceof ApiError + ? error.message + : 'Не удалось загрузить'} +

+ ) : ( + saveMutation.mutate(values)} + isSaving={saveMutation.isPending} + /> + )} +
+ +
+ ) +} diff --git a/apps/web/src/routes/_auth.apps.tsx b/apps/web/src/routes/_auth.apps.tsx index 76d65f5..45ea4ed 100644 --- a/apps/web/src/routes/_auth.apps.tsx +++ b/apps/web/src/routes/_auth.apps.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { LayoutGridIcon } from 'lucide-react' -import { APPS, buildSsoRedirectUrl, type AppId } from '@authportal/shared' +import { buildSsoRedirectUrl, type AppId } from '@authportal/shared' import { PageShell } from '@/components/page-shell' import { Badge } from '@/components/reui/badge' import { @@ -15,24 +15,14 @@ import { import { Button } from '@authportal/ui/components/button' import { Skeleton } from '@authportal/ui/components/skeleton' import { getToken } from '@/lib/auth' -import { meQueryOptions } from '@/queries/auth' +import { catalogQueryOptions, meQueryOptions } from '@/queries/auth' export const Route = createFileRoute('/_auth/apps')({ component: AppsPage, }) -const APP_URL_OVERRIDES: Partial> = { - vps: import.meta.env.VITE_VPS_APP_URL, - cfdm: import.meta.env.VITE_CFDM_APP_URL, - bgp: import.meta.env.VITE_BGP_APP_URL, -} - -function appLaunchUrl(appId: AppId, defaultUrl: string): string { - return APP_URL_OVERRIDES[appId] || defaultUrl -} - -function openApp(appId: AppId, defaultUrl: string) { - const base = appLaunchUrl(appId, defaultUrl).replace(/\/$/, '') +function openApp(_appId: AppId, baseUrl: string) { + const base = baseUrl.replace(/\/$/, '') const token = getToken() if (!token) { window.open(base, '_blank', 'noreferrer') @@ -44,9 +34,13 @@ function openApp(appId: AppId, defaultUrl: string) { } function AppsPage() { - const { data: me, isLoading } = useQuery(meQueryOptions) + const { data: me, isLoading: meLoading } = useQuery(meQueryOptions) + const { data: catalog, isLoading: catalogLoading } = useQuery( + catalogQueryOptions, + ) + const isLoading = meLoading || catalogLoading const allowed = new Set(me?.apps ?? []) - const apps = APPS.filter((app) => allowed.has(app.id)) + const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id)) return ( @@ -61,10 +55,10 @@ function AppsPage() { ) : null} diff --git a/docs/integrate-cfdm.md b/docs/integrate-cfdm.md index 7b33bfc..6a5de36 100644 --- a/docs/integrate-cfdm.md +++ b/docs/integrate-cfdm.md @@ -92,6 +92,14 @@ pnpm --filter web dev `AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`. +## App Switcher + +Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (CORS open). CFDM chrome (`AppSwitcher` / `AppsMenu`) читает его через `ensureAuthConfig().portalUrl`; offline fallback — hardcoded defaults с ids `cfdm` | `vps` | `bgp`. + +Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В CFDM Settings → Integrations — read-only ссылка на портал. + +`CURRENT_APP_ID = cfdm`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом. + ## UI аккаунта SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`. diff --git a/docs/integrate-vps-tracker.md b/docs/integrate-vps-tracker.md index 32fc049..d565cce 100644 --- a/docs/integrate-vps-tracker.md +++ b/docs/integrate-vps-tracker.md @@ -99,6 +99,14 @@ pnpm --filter web dev # :5173 `AUTH_REQUIRED=false` — auth выключен (удобно для локальной разработки без portal); данные в `space-main`. +## App Switcher + +Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`. VPS chrome читает его через `ensureAuthConfig().portalUrl`; offline fallback — defaults с ids `cfdm` | `vps` | `bgp`. + +Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В VPS Settings → Integrations — read-only ссылка. + +`CURRENT_APP_ID = vps`. Фильтр меню по JWT `apps[]` при наличии claims. + ## Troubleshooting | Симптом | Причина | @@ -109,7 +117,7 @@ pnpm --filter web dev # :5173 | Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` | | Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с | | «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?return_to=…` при живой portal-сессии. Нужен редирект на **`/logout`** (см. ниже) | -| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token | +| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token; public app-switcher GET тоже CORS-open | ## Logout (SSO) diff --git a/docs/ui-design-contract.md b/docs/ui-design-contract.md index 6524e23..5e19761 100644 --- a/docs/ui-design-contract.md +++ b/docs/ui-design-contract.md @@ -25,7 +25,9 @@ Surface lock: **`frame`** (ReUI Frame). Не смешивать shadcn Card и F Nav groups Auth Portal: - **Портал:** Приложения (`/apps`) -- **Админ** (только `is_admin`): Пользователи (`/admin`) +- **Админ** (только `is_admin`): Пользователи (`/admin`), Ссылки приложений (`/admin/apps`) + +App Switcher (source of truth): `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. UI: `/admin/apps` ([settings-16](https://reui.io/preview/base/settings-16)). Ids: `cfdm` · `vps` · `bgp`. Consumers (CFDM, vps-tracker) только читают public API. ## Spacing diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index e75b7b9..39f895c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -60,6 +60,12 @@ export function migrateSchema(sqlite: Sqlite): void { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS portal_settings ( + id TEXT PRIMARY KEY NOT NULL, + app_switcher_json TEXT, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id); CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id); @@ -72,3 +78,4 @@ export function healthCheck(sqlite: Sqlite): void { export * from './schema/index.js' export * from './users.js' +export * from './settings.js' diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4d152c7..0254809 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,3 +35,10 @@ export const refreshSessions = sqliteTable('refresh_sessions', { revokedAt: text('revoked_at'), createdAt: text('created_at').notNull(), }) + +/** Singleton row id = 'main' */ +export const portalSettings = sqliteTable('portal_settings', { + id: text('id').primaryKey(), + appSwitcherJson: text('app_switcher_json'), + updatedAt: text('updated_at').notNull(), +}) diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts new file mode 100644 index 0000000..b5883d5 --- /dev/null +++ b/packages/db/src/settings.ts @@ -0,0 +1,54 @@ +import { eq } from 'drizzle-orm' +import { + defaultAppSwitcherConfig, + normalizeAppSwitcherConfig, + parseAppSwitcherConfig, + type AppSwitcherConfig, +} from '@authportal/shared' +import type { AppDb } from './index.js' +import { portalSettings } from './schema/index.js' + +const SETTINGS_ID = 'main' + +export function getAppSwitcherConfig(db: AppDb): AppSwitcherConfig { + const row = db + .select() + .from(portalSettings) + .where(eq(portalSettings.id, SETTINGS_ID)) + .get() + if (!row?.appSwitcherJson) return defaultAppSwitcherConfig() + try { + return parseAppSwitcherConfig(JSON.parse(row.appSwitcherJson)) + } catch { + return defaultAppSwitcherConfig() + } +} + +export function setAppSwitcherConfig( + db: AppDb, + config: AppSwitcherConfig, +): AppSwitcherConfig { + const normalized = normalizeAppSwitcherConfig(config) + const now = new Date().toISOString() + const json = JSON.stringify(normalized) + const existing = db + .select() + .from(portalSettings) + .where(eq(portalSettings.id, SETTINGS_ID)) + .get() + if (existing) { + db.update(portalSettings) + .set({ appSwitcherJson: json, updatedAt: now }) + .where(eq(portalSettings.id, SETTINGS_ID)) + .run() + } else { + db.insert(portalSettings) + .values({ + id: SETTINGS_ID, + appSwitcherJson: json, + updatedAt: now, + }) + .run() + } + return normalized +} diff --git a/packages/shared/src/contracts/app-switcher.ts b/packages/shared/src/contracts/app-switcher.ts new file mode 100644 index 0000000..d5f46fe --- /dev/null +++ b/packages/shared/src/contracts/app-switcher.ts @@ -0,0 +1,96 @@ +import { z } from 'zod' +import { APP_IDS, APPS, appIdSchema, type AppId, type AppMeta } from './auth.js' + +export const appSwitcherIconSchema = z.enum([ + 'server', + 'cloud', + 'globe', + 'dashboard', + 'chart', +]) + +export const appSwitcherEntrySchema = z.object({ + id: appIdSchema, + name: z.string().min(1), + subtitle: z.string().optional(), + url: z.string().url(), + icon: appSwitcherIconSchema, + shortcut: z.string().optional(), + enabled: z.boolean(), + sort: z.number().int().optional(), +}) + +export const appSwitcherConfigSchema = z.object({ + menuLabel: z.string().min(1), + apps: z.array(appSwitcherEntrySchema).min(1), +}) + +export type AppSwitcherIconName = z.infer +export type AppSwitcherEntry = z.infer +export type AppSwitcherConfig = z.infer + +const DEFAULT_ICONS: Record = { + cfdm: 'cloud', + vps: 'server', + bgp: 'globe', +} + +/** Seed / fallback when DB is empty. */ +export function defaultAppSwitcherConfig(): AppSwitcherConfig { + return { + menuLabel: 'Приложения', + apps: APPS.map((app, index) => ({ + id: app.id, + name: app.title, + subtitle: app.description, + url: app.url, + icon: DEFAULT_ICONS[app.id], + enabled: true, + sort: index, + })), + } +} + +export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig { + const parsed = appSwitcherConfigSchema.safeParse(raw) + if (!parsed.success) return defaultAppSwitcherConfig() + return normalizeAppSwitcherConfig(parsed.data) +} + +/** Ensure all APP_IDS present; sort; drop unknown. */ +export function normalizeAppSwitcherConfig( + config: AppSwitcherConfig, +): AppSwitcherConfig { + const byId = new Map(config.apps.map((a) => [a.id, a])) + const defaults = defaultAppSwitcherConfig() + const apps = APP_IDS.map((id, index) => { + const existing = byId.get(id) + const fallback = defaults.apps.find((a) => a.id === id)! + return { + ...fallback, + ...existing, + id, + sort: existing?.sort ?? index, + enabled: existing?.enabled ?? true, + icon: existing?.icon ?? fallback.icon, + } + }).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)) + + return { + menuLabel: config.menuLabel || 'Приложения', + apps, + } +} + +/** AppMeta list with URLs from switcher store (for /apps + catalog). */ +export function appsMetaFromSwitcher(config: AppSwitcherConfig): AppMeta[] { + const normalized = normalizeAppSwitcherConfig(config) + return normalized.apps + .filter((a) => a.enabled !== false) + .map((a) => ({ + id: a.id, + title: a.name, + description: a.subtitle ?? APPS.find((x) => x.id === a.id)?.description ?? '', + url: a.url, + })) +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b14a8d..707bd58 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,3 @@ export * from './contracts/auth.js' +export * from './contracts/app-switcher.js' +