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
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:
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user