feat(app-switcher): централизовать ссылки приложений в portal settings
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-18 20:59:01 +07:00
co-authored by Cursor
parent 83bfc0355d
commit 0fa4b7adea
18 changed files with 662 additions and 46 deletions
+17
View File
@@ -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<void> {
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)
})
}
+17 -10
View File
@@ -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<void> {
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<void> {
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,
}
},
)
}
+94
View File
@@ -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()
})
})
+12 -2
View File
@@ -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() {
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Пользователи"
isActive={isActive(pathname, '/admin', false)}
isActive={isActive(pathname, '/admin', false) && !pathname.startsWith('/admin/apps')}
render={<Link to="/admin" />}
>
<UsersIcon className="size-4" />
<span>Пользователи</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/admin/apps', false)}
render={<Link to="/admin/apps" />}
>
<AppWindowIcon className="size-4" />
<span>Ссылки приложений</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
+22 -12
View File
@@ -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 (
<SidebarMenu>
@@ -67,15 +77,15 @@ export function AppSwitcher() {
sideOffset={4}
>
<div className="text-muted-foreground px-2 py-1.5 text-xs">
Приложения
{menuLabel}
</div>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICONS[app.id]
{apps.map((app) => {
const Icon = ICON_MAP[app.icon] ?? ServerIcon
return (
<DropdownMenuItem
key={app.id}
@@ -83,7 +93,7 @@ export function AppSwitcher() {
render={<a href={app.url} target="_blank" rel="noreferrer" />}
>
<Icon />
{app.title}
{app.name}
</DropdownMenuItem>
)
})}
@@ -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 (
<form
className="flex flex-col gap-5"
onSubmit={(e) =>
void form.handleSubmit((values) => onSave(values))(e)
}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="menu-label">Заголовок меню</FieldLabel>
<Input id="menu-label" {...form.register('menuLabel')} />
</Field>
{APP_IDS.map((appId, index) => {
const apps = form.watch('apps')
const appIndex = apps.findIndex((a) => a.id === appId)
if (appIndex < 0) return null
return (
<div key={appId} className="flex flex-col gap-3">
{index > 0 ? <ItemSeparator /> : null}
<p className="text-sm font-medium tracking-tight">
{appId.toUpperCase()}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`name-${appId}`}>Название</FieldLabel>
<Input
id={`name-${appId}`}
{...form.register(`apps.${appIndex}.name`)}
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`url-${appId}`}>URL</FieldLabel>
<Input
id={`url-${appId}`}
{...form.register(`apps.${appIndex}.url`)}
placeholder="https://…"
/>
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor={`subtitle-${appId}`}>
Описание
</FieldLabel>
<Input
id={`subtitle-${appId}`}
{...form.register(`apps.${appIndex}.subtitle`)}
/>
</Field>
<Field>
<FieldLabel htmlFor={`icon-${appId}`}>Иконка</FieldLabel>
<Select
value={form.watch(`apps.${appIndex}.icon`)}
onValueChange={(v) =>
form.setValue(
`apps.${appIndex}.icon`,
(v ?? 'server') as AppSwitcherIconName,
{ shouldDirty: true },
)
}
>
<SelectTrigger id={`icon-${appId}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ICON_OPTIONS.map((icon) => (
<SelectItem key={icon} value={icon}>
{icon}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal" className="items-center pt-6">
<Switch
checked={form.watch(`apps.${appIndex}.enabled`) !== false}
onCheckedChange={(v) =>
form.setValue(`apps.${appIndex}.enabled`, v, {
shouldDirty: true,
})
}
/>
<FieldLabel className="font-normal">Включено в switcher</FieldLabel>
</Field>
</div>
<input
type="hidden"
{...form.register(`apps.${appIndex}.id`)}
value={appId satisfies AppId}
/>
</div>
)
})}
</FieldGroup>
<Button
type="submit"
className="w-fit"
disabled={isSaving || !form.formState.isDirty}
>
{isSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</form>
)
}
+21
View File
@@ -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<AppSwitcherConfig>('/api/v1/app-switcher'),
staleTime: 60_000,
})
export const adminAppSwitcherQueryOptions = queryOptions({
queryKey: adminAppSwitcherQueryKey,
queryFn: () => api.get<AppSwitcherConfig>('/api/v1/admin/app-switcher'),
})
export function putAppSwitcher(config: AppSwitcherConfig) {
return api.put<AppSwitcherConfig>('/api/v1/admin/app-switcher', config)
}
+33 -2
View File
@@ -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,
}
+89
View File
@@ -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 (
<PageShell>
<div className="flex flex-col gap-px">
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
<p className="text-muted-foreground text-sm">
URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)
</p>
</div>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Ссылки сервисов</FrameTitle>
<FrameDescription>
Публичный конфиг: GET /api/v1/app-switcher читают приложения
</FrameDescription>
</FrameHeader>
<FramePanel>
{isLoading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-9 w-full max-w-md" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : isError ? (
<p className="text-destructive text-sm">
{error instanceof ApiError
? error.message
: 'Не удалось загрузить'}
</p>
) : (
<AppSwitcherAdminEditor
defaultValues={data ?? defaultAppSwitcherConfig()}
onSave={(values) => saveMutation.mutate(values)}
isSaving={saveMutation.isPending}
/>
)}
</FramePanel>
</Frame>
</PageShell>
)
}
+12 -18
View File
@@ -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<Record<AppId, string | undefined>> = {
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 (
<PageShell>
@@ -61,10 +55,10 @@ function AppsPage() {
<Button
variant="outline"
size="sm"
render={<Link to="/admin" />}
render={<Link to="/admin/apps" />}
nativeButton={false}
>
Админка
Ссылки приложений
</Button>
) : null}
</div>
+8
View File
@@ -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`.
+9 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+7
View File
@@ -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'
+7
View File
@@ -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(),
})
+54
View File
@@ -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
}
@@ -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<typeof appSwitcherIconSchema>
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
const DEFAULT_ICONS: Record<AppId, AppSwitcherIconName> = {
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,
}))
}
+2
View File
@@ -1 +1,3 @@
export * from './contracts/auth.js'
export * from './contracts/app-switcher.js'