Update CDN Manager: Add integration for new settings route, enhance permissions for nodes and aliases, and improve Docker configuration documentation. Update UI components for better navigation and user experience.
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 56s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 33s

This commit is contained in:
Denozordec
2026-09-04 13:11:33 +07:00
parent 8700dc2957
commit dd3c378b5f
18 changed files with 788 additions and 5 deletions
+49
View File
@@ -44,6 +44,55 @@ const RULES: Rule[] = [
match: (p) => p.startsWith("/api/v1/settings"),
permission: "cdn:settings:admin",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/v1/dashboard"),
permission: "cdn:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p === "/api/v1/topology",
permission: "cdn:topology:read",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/nodes") || p.startsWith("/api/v1/locations"),
permission: "cdn:nodes:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/nodes"),
permission: "cdn:nodes:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/v1/aliases"),
permission: "cdn:aliases:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/aliases"),
permission: "cdn:aliases:write",
},
{
methods: ["POST"],
match: (p) =>
/\/api\/v1\/zones\/[^/]+\/(sync|apply)/.test(p) ||
p.includes("/ignored-orphans"),
permission: "cdn:sync:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/v1/zones") || p.startsWith("/api/v1/cloudflare"),
permission: "cdn:zones:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/v1/zones"),
permission: "cdn:zones:write",
},
];
/** Resolve required permission for method+path, or null if public / unknown. */
+8 -1
View File
@@ -9,6 +9,7 @@ import {
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/nav-user'
import { can, permissionForPath } from '@/lib/auth'
import {
Sidebar,
SidebarContent,
@@ -52,6 +53,12 @@ function isNavActive(
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleNav = mainNav.filter((item) => {
const path =
'matchPrefix' in item && item.matchPrefix ? item.matchPrefix : item.to
const perm = permissionForPath(path)
return !perm || can(perm)
})
return (
<Sidebar collapsible="icon">
@@ -63,7 +70,7 @@ export function AppSidebar() {
<SidebarGroupLabel>CDN Manager</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{mainNav.map((item) => (
{visibleNav.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
+1 -1
View File
@@ -38,7 +38,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
{
id: 'cdn',
name: 'CDN Manager',
subtitle: 'Управление CDN',
subtitle: 'Флот DNS: ноды и алиасы',
url: 'https://cdn.shnt.top',
icon: 'cloud',
},
+12 -1
View File
@@ -234,12 +234,23 @@ export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
return 'cdn:dashboard:read'
}
if (pathname.startsWith('/nodes')) return 'cdn:nodes:read'
if (pathname.startsWith('/aliases')) return 'cdn:aliases:read'
if (pathname.startsWith('/topology')) return 'cdn:topology:read'
if (pathname.startsWith('/zones')) return 'cdn:zones:read'
if (pathname.startsWith('/settings')) return 'cdn:settings:admin'
return 'cdn:dashboard:read'
}
export function firstAllowedPath(): string {
const candidates = ['/', '/settings/appearance']
const candidates = [
'/',
'/nodes',
'/aliases',
'/topology',
'/zones',
'/settings/appearance',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
+1
View File
@@ -14,6 +14,7 @@ const routeTitles: Record<string, string> = {
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/cloudflare': 'Cloudflare',
'/settings/integrations': 'Интеграции',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
+22
View File
@@ -19,6 +19,7 @@ import { Route as AuthNodesRouteImport } from './routes/_auth/nodes'
import { Route as AuthAliasesRouteImport } from './routes/_auth/aliases'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthSettingsCloudflareRouteImport } from './routes/_auth/settings/cloudflare'
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
@@ -71,6 +72,12 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsIntegrationsRoute =
AuthSettingsIntegrationsRouteImport.update({
id: '/integrations',
path: '/integrations',
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthSettingsCloudflareRoute = AuthSettingsCloudflareRouteImport.update({
id: '/cloudflare',
path: '/cloudflare',
@@ -93,6 +100,7 @@ export interface FileRoutesByFullPath {
'/auth/callback': typeof AuthCallbackRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRoutesByTo {
@@ -105,6 +113,7 @@ export interface FileRoutesByTo {
'/': typeof AuthIndexRoute
'/settings/appearance': typeof AuthSettingsAppearanceRoute
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/settings': typeof AuthSettingsIndexRoute
}
export interface FileRoutesById {
@@ -120,6 +129,7 @@ export interface FileRoutesById {
'/_auth/': typeof AuthIndexRoute
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
'/_auth/settings/cloudflare': typeof AuthSettingsCloudflareRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
@@ -135,6 +145,7 @@ export interface FileRouteTypes {
| '/auth/callback'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings/integrations'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -147,6 +158,7 @@ export interface FileRouteTypes {
| '/'
| '/settings/appearance'
| '/settings/cloudflare'
| '/settings/integrations'
| '/settings'
id:
| '__root__'
@@ -161,6 +173,7 @@ export interface FileRouteTypes {
| '/_auth/'
| '/_auth/settings/appearance'
| '/_auth/settings/cloudflare'
| '/_auth/settings/integrations'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
@@ -242,6 +255,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/integrations': {
id: '/_auth/settings/integrations'
path: '/integrations'
fullPath: '/settings/integrations'
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/settings/cloudflare': {
id: '/_auth/settings/cloudflare'
path: '/cloudflare'
@@ -262,12 +282,14 @@ declare module '@tanstack/react-router' {
interface AuthSettingsRouteRouteChildren {
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
AuthSettingsCloudflareRoute: typeof AuthSettingsCloudflareRoute
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
}
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
AuthSettingsCloudflareRoute: AuthSettingsCloudflareRoute,
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
}
@@ -0,0 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { ExternalLinkIcon, LayoutGridIcon } from 'lucide-react'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@cdnmanager/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cdnmanager/ui/components/item'
export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage,
})
function SettingsIntegrationsPage() {
const { config: appSwitcher } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
const portalConnected = isAuthEnabled()
return (
<div className="flex w-full flex-col gap-5">
<section className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Auth Portal</h2>
<p className="text-muted-foreground text-sm">
SSO, App Switcher и права <code className="text-xs">cdn:*</code>
</p>
</div>
<Frame>
<FrameHeader className="sr-only">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Ссылки приложений настраиваются на auth-portal
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<Item className="items-center gap-3 border-0 px-3.5 py-3 sm:px-4">
<ItemMedia variant="icon">
<LayoutGridIcon aria-hidden="true" />
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">App Switcher</span>
<Badge variant={portalConnected ? 'success' : 'secondary'}>
{portalConnected ? 'SSO' : 'Локальный режим'}
</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-2">
{appSwitcher.apps.length} приложений · меню «
{appSwitcher.menuLabel}» · редактор только на портале
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto shrink-0">
{portalConnected ? (
<Button
type="button"
variant="outline"
size="sm"
render={<a href={portalAppsUrl} />}
>
Открыть на портале
<ExternalLinkIcon
data-icon="inline-end"
aria-hidden="true"
/>
</Button>
) : null}
</ItemActions>
</Item>
</ItemGroup>
</FramePanel>
</Frame>
</section>
</div>
)
}
+8 -2
View File
@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { CloudIcon, PaletteIcon } from 'lucide-react'
import { CloudIcon, LayoutGridIcon, PaletteIcon } from 'lucide-react'
import { SettingsShell, type SettingsTabConfig } from '@/components/reui-kit'
export const Route = createFileRoute('/_auth/settings')({
@@ -19,12 +19,18 @@ const SETTINGS_TABS: SettingsTabConfig[] = [
label: 'Cloudflare',
icon: <CloudIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <LayoutGridIcon className="size-4" aria-hidden="true" />,
},
]
function SettingsLayout() {
return (
<SettingsShell
description="Внешний вид и параметры Cloudflare DNS"
description="Внешний вид, Cloudflare DNS и auth-portal"
tabs={SETTINGS_TABS}
/>
)