From fc4f234cc5ddab72c279ec153f7da376c875cc49 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 12 Aug 2026 12:24:10 +0700 Subject: [PATCH] feat(directories): add create dialogs for communities and DoH profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На странице Справочники добавлены кнопки «Добавить» и FormDrawer для POST /v1/communities и /v1/doh-profiles. Доступ ограничен bgp:directories:write (sessionCanWriteDirectories). Co-authored-by: Cursor --- .../directories/community-create-dialog.tsx | 84 +++++++++++++ .../directories/doh-profile-create-dialog.tsx | 118 ++++++++++++++++++ apps/web/src/lib/auth.ts | 25 ++++ apps/web/src/queries/directories.ts | 43 ++++++- apps/web/src/routes/_auth/directories.tsx | 55 ++++++-- 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/directories/community-create-dialog.tsx create mode 100644 apps/web/src/components/directories/doh-profile-create-dialog.tsx diff --git a/apps/web/src/components/directories/community-create-dialog.tsx b/apps/web/src/components/directories/community-create-dialog.tsx new file mode 100644 index 0000000..ac55f56 --- /dev/null +++ b/apps/web/src/components/directories/community-create-dialog.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' +import { Input } from '@evobgp/ui/components/input' +import { Label } from '@evobgp/ui/components/label' + +import { FormDrawer } from '@/components/form-drawer' +import { LoadingButton } from '@/components/loading-button' +import { useCreateCommunityMutation } from '@/queries/directories' +import type { BgpCommunityCreate } from '@/types/api' + +interface CommunityCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDialogProps) { + const createMutation = useCreateCommunityMutation() + const [community, setCommunity] = useState('') + const [title, setTitle] = useState('') + + useEffect(() => { + if (!open) return + setCommunity('') + setTitle('') + }, [open]) + + async function save() { + const value = community.trim() + if (!value) { + toast.error('Укажите community') + return + } + const body: BgpCommunityCreate = { community: value } + const t = title.trim() + if (t) body.title = t + try { + await createMutation.mutateAsync(body) + onOpenChange(false) + } catch { + // toast in mutation + } + } + + return ( + + + void save()}> + Создать + + + } + > +
+ + setCommunity(e.target.value)} + /> +
+
+ + setTitle(e.target.value)} + /> +
+
+ ) +} diff --git a/apps/web/src/components/directories/doh-profile-create-dialog.tsx b/apps/web/src/components/directories/doh-profile-create-dialog.tsx new file mode 100644 index 0000000..68699b3 --- /dev/null +++ b/apps/web/src/components/directories/doh-profile-create-dialog.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' +import { Input } from '@evobgp/ui/components/input' +import { Label } from '@evobgp/ui/components/label' + +import { FormDrawer } from '@/components/form-drawer' +import { LoadingButton } from '@/components/loading-button' +import { useCreateDohProfileMutation } from '@/queries/directories' +import type { DohProfileCreate } from '@/types/api' + +interface DohProfileCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateDialogProps) { + const createMutation = useCreateDohProfileMutation() + const [name, setName] = useState('') + const [url, setUrl] = useState('') + const [timeoutMs, setTimeoutMs] = useState('') + + useEffect(() => { + if (!open) return + setName('') + setUrl('') + setTimeoutMs('') + }, [open]) + + async function save() { + const trimmedUrl = url.trim() + if (!trimmedUrl) { + toast.error('Укажите URL DoH') + return + } + let urlOk = true + try { + new URL(trimmedUrl) + } catch { + urlOk = false + } + if (!urlOk) { + toast.error('Некорректный URL') + return + } + + const body: DohProfileCreate = { url: trimmedUrl } + const n = name.trim() + if (n) body.name = n + if (timeoutMs.trim() !== '') { + const ms = Number(timeoutMs) + if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) { + toast.error('Timeout должен быть целым числом > 0') + return + } + body.timeout_ms = ms + } + + try { + await createMutation.mutateAsync(body) + onOpenChange(false) + } catch { + // toast in mutation + } + } + + return ( + + + void save()}> + Создать + + + } + > +
+ + setName(e.target.value)} + /> +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setTimeoutMs(e.target.value)} + /> +
+
+ ) +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 6c44222..f7159a9 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: { return role === 'editor' || role === 'operator' } +/** + * Whether session may create/update directories (`bgp:directories:write`). + * Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+. + */ +export function sessionCanWriteDirectories(session: { + role?: string + kind?: string + is_admin?: boolean + permissions?: readonly string[] +} | null | undefined): boolean { + if (!session) return false + const jwtPath = + session.kind === 'jwt' || + session.is_admin === true || + (session.permissions?.length ?? 0) > 0 + if (jwtPath) { + return ( + session.is_admin === true || + hasPermission(session.permissions ?? [], 'bgp:directories:write') + ) + } + const role = (session.role ?? '').toLowerCase() + return role === 'editor' || role === 'operator' +} + /** Nav path → minimum permission to show the item. Sync with app-shell NAV. */ export function permissionForPath(pathname: string): string | null { if (pathname === '/' || pathname.startsWith('/dashboard')) { diff --git a/apps/web/src/queries/directories.ts b/apps/web/src/queries/directories.ts index 925f218..aa57574 100644 --- a/apps/web/src/queries/directories.ts +++ b/apps/web/src/queries/directories.ts @@ -1,6 +1,15 @@ -import { queryOptions } from '@tanstack/react-query' -import { apiJSON } from '@/lib/api-client' -import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api' +import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { apiJSON, apiMutate } from '@/lib/api-client' +import type { + BgpCommunity, + BgpCommunityCreate, + CommunitiesResponse, + DohProfile, + DohProfileCreate, + DohProfilesResponse, +} from '@/types/api' export const directoriesKeys = { all: ['directories'] as const, @@ -23,3 +32,31 @@ export function directoriesDohQueryOptions() { staleTime: 30_000, }) } + +export function useCreateCommunityMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: BgpCommunityCreate) => + apiMutate('/v1/communities', 'POST', body, { idempotent: false }), + onSuccess: () => { + toast.success('Сообщество создано') + void qc.invalidateQueries({ queryKey: directoriesKeys.communities() }) + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'), + }) +} + +export function useCreateDohProfileMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: DohProfileCreate) => + apiMutate('/v1/doh-profiles', 'POST', body, { idempotent: false }), + onSuccess: () => { + toast.success('DoH-профиль создан') + void qc.invalidateQueries({ queryKey: directoriesKeys.doh() }) + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'), + }) +} diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx index 0dcc587..66646d2 100644 --- a/apps/web/src/routes/_auth/directories.tsx +++ b/apps/web/src/routes/_auth/directories.tsx @@ -1,16 +1,21 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { BookText, Globe, RefreshCw, Tags } from 'lucide-react' +import { BookText, Globe, Plus, RefreshCw, Tags } from 'lucide-react' +import { useState } from 'react' import { Button } from '@evobgp/ui/components/button' -import { Badge } from '@/components/reui/badge' import { BadgeTabs, TabsContent } from '@/components/badge-tabs' -import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' +import { CommunityCreateDialog } from '@/components/directories/community-create-dialog' import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid' import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid' +import { DohProfileCreateDialog } from '@/components/directories/doh-profile-create-dialog' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' +import { Badge } from '@/components/reui/badge' +import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit' import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons' +import { sessionCanWriteDirectories } from '@/lib/auth' +import { authSessionQueryOptions } from '@/queries/auth' import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories' export const Route = createFileRoute('/_auth/directories')({ @@ -18,6 +23,12 @@ export const Route = createFileRoute('/_auth/directories')({ }) function DirectoriesComponent() { + const [communityOpen, setCommunityOpen] = useState(false) + const [dohOpen, setDohOpen] = useState(false) + + const sessionQ = useQuery(authSessionQueryOptions()) + const canWrite = sessionCanWriteDirectories(sessionQ.data) + const communitiesQ = useQuery(directoriesCommunitiesQueryOptions()) const dohQ = useQuery(directoriesDohQueryOptions()) @@ -58,6 +69,20 @@ function DirectoriesComponent() { }, ] + const addCommunityButton = canWrite ? ( + + ) : null + + const addDohButton = canWrite ? ( + + ) : null + return (
} onRetry={() => communitiesQ.refetch()} > - {(items) => ( + {(rows) => ( )} @@ -114,7 +141,11 @@ function DirectoriesComponent() { - + } onRetry={() => dohQ.refetch()} > - {(items) => ( + {(rows) => ( )} @@ -135,6 +167,13 @@ function DirectoriesComponent() { + + {canWrite ? ( + <> + + + + ) : null}
) }