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 <[email protected]>
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: '[email protected]',
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: '[email protected]', 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>