From a902a4270d635f5cab9f731092668a4113b547fd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Jul 2026 20:32:17 +0700 Subject: [PATCH] fix(api-keys): enhance API key management with new mutations and UI updates Added new mutations for creating, revoking, and rotating API keys in the api-keys query file. Updated the Access component to utilize these mutations, improving the user interface with better feedback and session management. Introduced a new AccessApiKeysCard for displaying API key information and enhanced the overall layout and user experience in the access route. --- .../access/access-api-keys-card.tsx | 190 ++++++++++++ .../access/api-key-create-dialog.tsx | 132 ++++++++ .../access/api-key-token-dialog.tsx | 51 ++++ apps/web/src/lib/access/api-key-labels.ts | 19 ++ apps/web/src/queries/api-keys.ts | 53 +++- apps/web/src/routes/_auth/access.tsx | 288 ++++++------------ apps/web/tsconfig.tsbuildinfo | 2 +- 7 files changed, 541 insertions(+), 194 deletions(-) create mode 100644 apps/web/src/components/access/access-api-keys-card.tsx create mode 100644 apps/web/src/components/access/api-key-create-dialog.tsx create mode 100644 apps/web/src/components/access/api-key-token-dialog.tsx create mode 100644 apps/web/src/lib/access/api-key-labels.ts diff --git a/apps/web/src/components/access/access-api-keys-card.tsx b/apps/web/src/components/access/access-api-keys-card.tsx new file mode 100644 index 0000000..c0fe108 --- /dev/null +++ b/apps/web/src/components/access/access-api-keys-card.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react' +import { Plus, RefreshCw, Trash2 } from 'lucide-react' + +import { Button } from '@evobgp/ui/components/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@evobgp/ui/components/table' + +import { ApiKeyCreateDialog } from '@/components/access/api-key-create-dialog' +import { ApiKeyTokenDialog } from '@/components/access/api-key-token-dialog' +import { Badge } from '@/components/reui/badge' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { QueryState } from '@/components/query-state' +import { StatusBadge } from '@/components/status-badge' +import { TableSkeleton } from '@/components/skeletons' +import { formatApiKeyDate } from '@/lib/access/api-key-labels' +import { useRevokeApiKeyMutation, useRotateApiKeyMutation } from '@/queries/api-keys' +import type { ApiKey, ApiKeyCreated } from '@/types/api' + +interface AccessApiKeysCardProps { + items: ApiKey[] + isLoading: boolean + isError: boolean + error: unknown + onRetry: () => void +} + +export function AccessApiKeysCard({ + items, + isLoading, + isError, + error, + onRetry, +}: AccessApiKeysCardProps) { + const [createOpen, setCreateOpen] = useState(false) + const [tokenDialogOpen, setTokenDialogOpen] = useState(false) + const [revealedToken, setRevealedToken] = useState('') + + const revoke = useRevokeApiKeyMutation() + const rotate = useRotateApiKeyMutation() + + function showToken(created: ApiKeyCreated) { + setRevealedToken(created.token) + setTokenDialogOpen(true) + } + + function handleRotated(id: string) { + rotate.mutate(id, { + onSuccess: (created) => showToken(created), + }) + } + + return ( + <> + + +
+ API-ключи + + Управление ключами tenant. Полный токен показывается только при создании и ротации. + +
+
+ + +
+
+ + } + onRetry={onRetry} + > + {(data) => ( + + + + Имя + Роль + Префикс + Статус + Истекает + Последнее использование + + + + + {data.map((k) => ( + + {k.name} + + + {k.role} + + + + {k.prefix}… + + + {k.revoked_at ? ( + + ) : ( + + )} + + + {formatApiKeyDate(k.expires_at)} + + + {formatApiKeyDate(k.last_used_at)} + + +
+ + + + } + title="Ротировать ключ?" + description="Старый токен перестанет работать сразу." + confirmLabel="Ротировать" + onConfirm={() => handleRotated(k.id)} + /> + + + + } + title="Отозвать API-ключ?" + description={`${k.name} (${k.prefix}…)`} + confirmLabel="Отозвать" + destructive + onConfirm={() => revoke.mutate(k.id)} + /> +
+
+
+ ))} +
+
+ )} +
+
+
+ + + + + + ) +} diff --git a/apps/web/src/components/access/api-key-create-dialog.tsx b/apps/web/src/components/access/api-key-create-dialog.tsx new file mode 100644 index 0000000..8f3e6e3 --- /dev/null +++ b/apps/web/src/components/access/api-key-create-dialog.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@evobgp/ui/components/dialog' +import { Input } from '@evobgp/ui/components/input' +import { Label } from '@evobgp/ui/components/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@evobgp/ui/components/select' + +import { LoadingButton } from '@/components/loading-button' +import { API_KEY_ROLE_ITEMS } from '@/lib/access/api-key-labels' +import { useCreateApiKeyMutation } from '@/queries/api-keys' +import type { ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '@/types/api' + +interface ApiKeyCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (created: ApiKeyCreated) => void +} + +export function ApiKeyCreateDialog({ open, onOpenChange, onCreated }: ApiKeyCreateDialogProps) { + const createMutation = useCreateApiKeyMutation() + const [name, setName] = useState('') + const [role, setRole] = useState('editor') + const [expiresLocal, setExpiresLocal] = useState('') + + useEffect(() => { + if (!open) return + setName('') + setRole('editor') + setExpiresLocal('') + }, [open]) + + function handleOpenChange(next: boolean) { + onOpenChange(next) + } + + async function save() { + if (!name.trim()) { + toast.error('Укажите имя') + return + } + const body: ApiKeyCreate = { + name: name.trim(), + role, + } + if (expiresLocal.trim()) { + const d = new Date(expiresLocal) + if (Number.isNaN(d.getTime())) { + toast.error('Некорректная дата истечения') + return + } + body.expires_at = d.toISOString() + } + try { + const created = await createMutation.mutateAsync(body) + onOpenChange(false) + onCreated(created) + } catch { + // toast handled in mutation + } + } + + return ( + + + + Новый API-ключ + +
+
+ + setName(e.target.value)} + placeholder="CI / оператор UI" + /> +
+
+ + +
+
+ + setExpiresLocal(e.target.value)} + /> +
+
+ + + + Создать + + +
+
+ ) +} diff --git a/apps/web/src/components/access/api-key-token-dialog.tsx b/apps/web/src/components/access/api-key-token-dialog.tsx new file mode 100644 index 0000000..9e9f19d --- /dev/null +++ b/apps/web/src/components/access/api-key-token-dialog.tsx @@ -0,0 +1,51 @@ +import { Copy } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@evobgp/ui/components/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@evobgp/ui/components/dialog' + +interface ApiKeyTokenDialogProps { + open: boolean + token: string + onOpenChange: (open: boolean) => void +} + +export function ApiKeyTokenDialog({ open, token, onOpenChange }: ApiKeyTokenDialogProps) { + async function copyToken() { + if (!token) return + try { + await navigator.clipboard.writeText(token) + toast.success('Скопировано') + } catch { + toast.error('Не удалось скопировать') + } + } + + return ( + + + + Сохраните токен + + Он больше не будет показан. Скопируйте в безопасное хранилище. + + +
{token}
+ + + + +
+
+ ) +} diff --git a/apps/web/src/lib/access/api-key-labels.ts b/apps/web/src/lib/access/api-key-labels.ts new file mode 100644 index 0000000..60dcd04 --- /dev/null +++ b/apps/web/src/lib/access/api-key-labels.ts @@ -0,0 +1,19 @@ +import type { ApiKeyRole } from '@/types/api' + +export const API_KEY_ROLE_ITEMS: ReadonlyArray<{ value: ApiKeyRole; label: string }> = [ + { value: 'viewer', label: 'viewer — только чтение' }, + { value: 'editor', label: 'editor — CRUD без apply' }, + { value: 'operator', label: 'operator — полный доступ' }, + { value: 'node', label: 'node — только API ноды' }, +] + +export function apiKeyRoleLabel(role: ApiKeyRole): string { + return API_KEY_ROLE_ITEMS.find((o) => o.value === role)?.label ?? role +} + +export function formatApiKeyDate(iso: string | null | undefined): string { + if (!iso) return '—' + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '—' + return d.toLocaleString('ru-RU') +} diff --git a/apps/web/src/queries/api-keys.ts b/apps/web/src/queries/api-keys.ts index fffab98..643f342 100644 --- a/apps/web/src/queries/api-keys.ts +++ b/apps/web/src/queries/api-keys.ts @@ -1,6 +1,8 @@ -import { queryOptions } from '@tanstack/react-query' -import { apiJSON } from '@/lib/api-client' -import type { ApiKey, ApiKeysResponse } from '@/types/api' +import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { apiJSON, apiMutate } from '@/lib/api-client' +import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeysResponse } from '@/types/api' export const apiKeysKeys = { all: ['api-keys'] as const, @@ -17,3 +19,48 @@ export function apiKeysQueryOptions() { staleTime: 60_000, }) } + +function invalidateApiKeysList(qc: ReturnType) { + void qc.invalidateQueries({ queryKey: apiKeysKeys.list() }) +} + +export function useCreateApiKeyMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: ApiKeyCreate) => + apiMutate('/v1/api-keys', 'POST', body, { idempotent: false }), + onSuccess: () => { + toast.success('Ключ создан') + invalidateApiKeysList(qc) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать ключ'), + }) +} + +export function useRevokeApiKeyMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }), + onSuccess: () => { + toast.success('Ключ отозван') + invalidateApiKeysList(qc) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'), + }) +} + +export function useRotateApiKeyMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: string) => + apiMutate(`/v1/api-keys/${id}/rotate`, 'POST', undefined, { + idempotent: false, + }), + onSuccess: () => { + toast.success('Ключ ротирован') + invalidateApiKeysList(qc) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'), + }) +} diff --git a/apps/web/src/routes/_auth/access.tsx b/apps/web/src/routes/_auth/access.tsx index 0c716e8..c084e72 100644 --- a/apps/web/src/routes/_auth/access.tsx +++ b/apps/web/src/routes/_auth/access.tsx @@ -1,29 +1,18 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' +import { Info, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react' +import { useMemo } from 'react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' +import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' import { Button } from '@evobgp/ui/components/button' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@evobgp/ui/components/table' -import { RefreshCw } from 'lucide-react' -import { toast } from 'sonner' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' +import { AccessApiKeysCard } from '@/components/access/access-api-keys-card' import { PageHeader } from '@/components/page-header' -import { QueryState } from '@/components/query-state' -import { ConfirmDialog } from '@/components/confirm-dialog' +import { SectionCards, type SectionCardItem } from '@/components/section-cards' +import { SectionCardsSkeleton } from '@/components/skeletons' import { authSessionQueryOptions } from '@/queries/auth' import { apiKeysQueryOptions } from '@/queries/api-keys' -import { apiMutate } from '@/lib/api-client' -import { useMutation } from '@tanstack/react-query' -import type { ApiKeyCreated } from '@/types/api' -import { useState } from 'react' -import { Copy } from 'lucide-react' export const Route = createFileRoute('/_auth/access')({ component: AccessComponent, @@ -39,20 +28,79 @@ function AccessComponent() { enabled: isOperator, }) + const keys = keysQuery.data ?? [] + const activeCount = keys.filter((k) => !k.revoked_at).length + const revokedCount = keys.filter((k) => k.revoked_at).length + + const refreshing = sessionQuery.isFetching || keysQuery.isFetching + + const kpiItems: SectionCardItem[] = useMemo( + () => [ + { + label: 'Всего ключей', + value: keys.length, + icon: , + hint: 'в tenant', + }, + { + label: 'Активных', + value: activeCount, + icon: , + hint: 'не отозваны', + }, + { + label: 'Отозванных', + value: revokedCount, + icon: , + hint: 'revoked', + variant: revokedCount > 0 ? 'warning' : 'default', + }, + ], + [keys.length, activeCount, revokedCount], + ) + + function refetchAll() { + void sessionQuery.refetch() + if (isOperator) void keysQuery.refetch() + } + return ( -
+
+ + Обновить + + ) : undefined + } /> + + + О API-ключах + + Роли: viewer (чтение),{' '} + editor (CRUD), operator{' '} + (apply и настройки), node (API ноды). Полный токен + показывается один раз при создании и ротации. Bearer для браузера — в{' '} + + настройках + + . + + + {session ? ( - + Текущая сессия Tenant и роль ключа, с которым открыта панель. - +

Tenant

{session.tenant_id}

@@ -63,183 +111,43 @@ function AccessComponent() {
- ) : null} + ) : ( + + + Не удалось определить сессию. Укажите Bearer-токен в{' '} + + настройках + {' '} + интерфейса. + + + )} {isOperator ? ( - keysQuery.refetch()} - /> + <> + {keysQuery.isLoading ? ( + + ) : ( + + )} + keysQuery.refetch()} + /> + ) : session ? ( Управление API-ключами доступно только роли operator. Текущая роль:{' '} - {session.role}. + {session.role}. Для выдачи ключей войдите с + operator-ключом или создайте ключ через API / переменную{' '} + EVOBGP_API_KEYS. ) : null}
) } - -function ApiKeysCard({ - items, - isLoading, - isError, - error, - onRetry, -}: { - items: import('@/types/api').ApiKey[] - isLoading: boolean - isError: boolean - error: unknown - onRetry: () => void -}) { - const [revealedToken, setRevealedToken] = useState(null) - - const revoke = useMutation({ - mutationFn: (id: string) => - apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }), - onSuccess: () => toast.success('Ключ отозван'), - onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'), - }) - - const rotate = useMutation({ - mutationFn: (id: string) => - apiMutate(`/v1/api-keys/${id}/rotate`, 'POST', undefined, { - idempotent: false, - }), - onSuccess: (created) => { - toast.success('Ключ ротирован') - setRevealedToken(created.token) - }, - onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'), - }) - - async function copyToken() { - if (!revealedToken) return - try { - await navigator.clipboard.writeText(revealedToken) - toast.success('Скопировано') - } catch { - toast.error('Не удалось скопировать') - } - } - - return ( - - -
- API-ключи - - Управление ключами tenant. Полный токен показывается только при создании и ротации. - -
- -
- - - {(data) => ( - - - - Имя - Роль - Префикс - Статус - - - - - {data.map((k) => ( - - {k.name} - {k.role} - {k.prefix}… - - {k.revoked_at ? ( - отозван - ) : ( - активен - )} - - -
- - - - } - title="Ротировать ключ?" - description="Старый токен перестанет работать сразу." - confirmLabel="Ротировать" - onConfirm={() => rotate.mutate(k.id)} - /> - - - - } - title="Отозвать API-ключ?" - description={`${k.name} (${k.prefix}…)`} - confirmLabel="Отозвать" - destructive - onConfirm={() => revoke.mutate(k.id)} - /> -
-
-
- ))} -
-
- )} -
-
- - {revealedToken ? ( -
-
Новый токен (сохраните сейчас):
-
- {revealedToken} -
-
- - -
-
- ) : null} -
- ) -} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 2c38b25..1aea66a 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/confirm-dialog.tsx","./src/components/empty-state.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/layout/app-shell.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/number-field.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/access/api-key-labels.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file