diff --git a/AGENTS.md b/AGENTS.md index b2e77eb..f541912 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,10 @@ pnpm --filter web build Env: `CLOUDFLARE_API_TOKEN` (Zone DNS Edit + Zone Read). Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`. +SSO (prod): [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) — `AUTH_REQUIRED=true`, общий `AUTH_JWT_SECRET` с portal, app id `cdn`. + +Docker CLI на сервере: [`docs/deploy-traefik.md`](docs/deploy-traefik.md) (Traefik + HTTPS) · [`docs/deploy-docker.md`](docs/deploy-docker.md) (plain). + ## UI (ReUI PRO + DNA CFDM) Surface: **frame**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md). @@ -70,3 +74,5 @@ GET /api/v1/cloudflare/zones ## CI / Docker Gitea: `.gitea/workflows/{ci,cd,quality}.yaml`. Образы: `deploy/docker` (`cdnmanager` + alias `cdn-manager`). +Prod: [`docs/deploy-traefik.md`](docs/deploy-traefik.md) — `deploy/docker-compose.traefik.yml` + `deploy/env.traefik.example`. +Без Traefik: [`docs/deploy-docker.md`](docs/deploy-docker.md) · Compose: `docker-compose.yml` / `deploy/compose/docker-compose.example.yaml`. diff --git a/README.md b/README.md index da11183..64f46c8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,24 @@ pnpm --filter web dev # :5176 CLOUDFLARE_API_TOKEN= # Zone DNS Edit + Zone Read ``` +## Auth Portal + +SSO: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md). + +## Docker (сервер) + +**Рекомендуется (HTTPS + Traefik):** [`docs/deploy-traefik.md`](docs/deploy-traefik.md) + +```bash +mkdir -p /opt/cdnmanager/data +cp deploy/docker-compose.traefik.yml /opt/cdnmanager/docker-compose.yml +cp deploy/env.traefik.example /opt/cdnmanager/.env # fill secrets +docker login git.shx.one +cd /opt/cdnmanager && docker compose pull && docker compose up -d +``` + +Без Traefik: [`docs/deploy-docker.md`](docs/deploy-docker.md). + ## Структура ``` diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 248bdc9..21181a1 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -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. */ diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 54c9c25..f18e6be 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -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 ( @@ -63,7 +70,7 @@ export function AppSidebar() { CDN Manager - {mainNav.map((item) => ( + {visibleNav.map((item) => ( = { const SETTINGS_SECTIONS: Record = { '/settings/appearance': 'Внешний вид', '/settings/cloudflare': 'Cloudflare', + '/settings/integrations': 'Интеграции', } /** Drop consecutive repeats so «Настройки» does not stack after tab switches. */ diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c145896..b1fb022 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, } diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx new file mode 100644 index 0000000..9efa86e --- /dev/null +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -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 ( +
+
+
+

Auth Portal

+

+ SSO, App Switcher и права cdn:* +

+
+ + + + App Switcher + + Ссылки приложений настраиваются на auth-portal + + + + + + + + + + App Switcher + + {portalConnected ? 'SSO' : 'Локальный режим'} + + + + {appSwitcher.apps.length} приложений · меню « + {appSwitcher.menuLabel}» · редактор только на портале + + + + {portalConnected ? ( + + ) : null} + + + + + +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/settings/route.tsx b/apps/web/src/routes/_auth/settings/route.tsx index 1b3346d..3511ab5 100644 --- a/apps/web/src/routes/_auth/settings/route.tsx +++ b/apps/web/src/routes/_auth/settings/route.tsx @@ -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: