feat(directories): add create dialogs for communities and DoH profiles
На странице Справочники добавлены кнопки «Добавить» и FormDrawer для POST /v1/communities и /v1/doh-profiles. Доступ ограничен bgp:directories:write (sessionCanWriteDirectories). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новое сообщество BGP"
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-value">Community</Label>
|
||||
<Input
|
||||
id="comm-value"
|
||||
placeholder="65000:100"
|
||||
value={community}
|
||||
onChange={(e) => setCommunity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-title">Название (опционально)</Label>
|
||||
<Input
|
||||
id="comm-title"
|
||||
placeholder="Отображаемое имя"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый DoH-профиль"
|
||||
description="Резолвер DNS-over-HTTPS для доменных модулей"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="doh-name"
|
||||
placeholder="Control D / AdGuard"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-url">URL</Label>
|
||||
<Input
|
||||
id="doh-url"
|
||||
type="url"
|
||||
placeholder="https://dns.example/dns-query"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="5000"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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<BgpCommunity>('/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<DohProfile>('/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-профиль'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<Button size="sm" type="button" onClick={() => setCommunityOpen(true)}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const addDohButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={() => setDohOpen(true)}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
@@ -92,6 +117,7 @@ function DirectoriesComponent() {
|
||||
<FrameDataGrid
|
||||
title="Сообщества BGP"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
actions={addCommunityButton}
|
||||
>
|
||||
<QueryState
|
||||
data={communities}
|
||||
@@ -100,12 +126,13 @@ function DirectoriesComponent() {
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
emptyAction={addCommunityButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesCommunitiesGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -114,7 +141,11 @@ function DirectoriesComponent() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-0">
|
||||
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||
<FrameDataGrid
|
||||
title="DoH профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
actions={addDohButton}
|
||||
>
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
@@ -122,12 +153,13 @@ function DirectoriesComponent() {
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
emptyAction={addDohButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesDohGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -135,6 +167,13 @@ function DirectoriesComponent() {
|
||||
</FrameDataGrid>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
|
||||
{canWrite ? (
|
||||
<>
|
||||
<CommunityCreateDialog open={communityOpen} onOpenChange={setCommunityOpen} />
|
||||
<DohProfileCreateDialog open={dohOpen} onOpenChange={setDohOpen} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user