Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a902a4270d | ||
|
|
3a3e6db018 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 48400,
|
||||
"pid": 59836,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||
"startedAt": 1783060842523
|
||||
"startedAt": 1783332305171
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
skeleton={<TableSkeleton rows={4} cols={6} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Префикс</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Истекает</TableHead>
|
||||
<TableHead>Последнее использование</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{k.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{k.prefix}…
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked_at ? (
|
||||
<StatusBadge status="error" label="отозван" />
|
||||
) : (
|
||||
<StatusBadge status="active" label="активен" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.expires_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatApiKeyDate(k.last_used_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at || rotate.isPending}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => handleRotated(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at || revoke.isPending}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => revoke.mutate(k.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ApiKeyCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={showToken}
|
||||
/>
|
||||
|
||||
<ApiKeyTokenDialog
|
||||
open={tokenDialogOpen}
|
||||
token={revealedToken}
|
||||
onOpenChange={setTokenDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<ApiKeyRole>('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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый API-ключ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-name">Имя</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="CI / оператор UI"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-role">Роль</Label>
|
||||
<Select
|
||||
items={[...API_KEY_ROLE_ITEMS]}
|
||||
value={role}
|
||||
onValueChange={(v) => v && setRole(v as ApiKeyRole)}
|
||||
>
|
||||
<SelectTrigger id="key-role" className="w-full">
|
||||
<SelectValue placeholder="Выберите роль" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{API_KEY_ROLE_ITEMS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="key-expires">Истекает (опционально)</Label>
|
||||
<Input
|
||||
id="key-expires"
|
||||
type="datetime-local"
|
||||
value={expiresLocal}
|
||||
onChange={(e) => setExpiresLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton onClick={save} loading={createMutation.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сохраните токен</DialogTitle>
|
||||
<DialogDescription>
|
||||
Он больше не будет показан. Скопируйте в безопасное хранилище.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">{token}</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={copyToken}>
|
||||
<Copy />
|
||||
Копировать
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<typeof useQueryClient>) {
|
||||
void qc.invalidateQueries({ queryKey: apiKeysKeys.list() })
|
||||
}
|
||||
|
||||
export function useCreateApiKeyMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: ApiKeyCreate) =>
|
||||
apiMutate<ApiKeyCreated>('/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<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
|
||||
idempotent: false,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Ключ ротирован')
|
||||
invalidateApiKeysList(qc)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: <KeyRound className="size-4" />,
|
||||
hint: 'в tenant',
|
||||
},
|
||||
{
|
||||
label: 'Активных',
|
||||
value: activeCount,
|
||||
icon: <ShieldCheck className="size-4" />,
|
||||
hint: 'не отозваны',
|
||||
},
|
||||
{
|
||||
label: 'Отозванных',
|
||||
value: revokedCount,
|
||||
icon: <ShieldOff className="size-4" />,
|
||||
hint: 'revoked',
|
||||
variant: revokedCount > 0 ? 'warning' : 'default',
|
||||
},
|
||||
],
|
||||
[keys.length, activeCount, revokedCount],
|
||||
)
|
||||
|
||||
function refetchAll() {
|
||||
void sessionQuery.refetch()
|
||||
if (isOperator) void keysQuery.refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
actions={
|
||||
isOperator ? (
|
||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert className="border-info/30 bg-info/5">
|
||||
<Info className="text-info" />
|
||||
<AlertTitle>О API-ключах</AlertTitle>
|
||||
<AlertDescription>
|
||||
Роли: <code className="text-xs">viewer</code> (чтение),{' '}
|
||||
<code className="text-xs">editor</code> (CRUD), <code className="text-xs">operator</code>{' '}
|
||||
(apply и настройки), <code className="text-xs">node</code> (API ноды). Полный токен
|
||||
показывается один раз при создании и ротации. Bearer для браузера — в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{session ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="border-b py-3">
|
||||
<CardTitle className="text-base">Текущая сессия</CardTitle>
|
||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<CardContent className="grid gap-3 p-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tenant</p>
|
||||
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
|
||||
@@ -63,183 +111,43 @@ function AccessComponent() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Не удалось определить сессию. Укажите Bearer-токен в{' '}
|
||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
||||
настройках
|
||||
</Link>{' '}
|
||||
интерфейса.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isOperator ? (
|
||||
<ApiKeysCard
|
||||
items={keysQuery.data ?? []}
|
||||
isLoading={keysQuery.isLoading}
|
||||
isError={keysQuery.isError}
|
||||
error={keysQuery.error}
|
||||
onRetry={() => keysQuery.refetch()}
|
||||
/>
|
||||
<>
|
||||
{keysQuery.isLoading ? (
|
||||
<SectionCardsSkeleton count={3} />
|
||||
) : (
|
||||
<SectionCards items={kpiItems} />
|
||||
)}
|
||||
<AccessApiKeysCard
|
||||
items={keys}
|
||||
isLoading={keysQuery.isLoading}
|
||||
isError={keysQuery.isError}
|
||||
error={keysQuery.error}
|
||||
onRetry={() => keysQuery.refetch()}
|
||||
/>
|
||||
</>
|
||||
) : session ? (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
|
||||
<span className="font-mono">{session.role}</span>.
|
||||
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
|
||||
operator-ключом или создайте ключ через API / переменную{' '}
|
||||
<code className="text-xs">EVOBGP_API_KEYS</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeysCard({
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
items: import('@/types/api').ApiKey[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [revealedToken, setRevealedToken] = useState<string | null>(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<ApiKeyCreated>(`/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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
|
||||
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<QueryState
|
||||
data={items}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Ключи можно создать через API."
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(data) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Имя</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead>Префикс</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{k.role}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}…</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked_at ? (
|
||||
<span className="text-sm text-destructive">отозван</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">активен</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Ротировать ключ?"
|
||||
description="Старый токен перестанет работать сразу."
|
||||
confirmLabel="Ротировать"
|
||||
onConfirm={() => rotate.mutate(k.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive"
|
||||
disabled={!!k.revoked_at}
|
||||
title="Отозвать"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
title="Отозвать API-ключ?"
|
||||
description={`${k.name} (${k.prefix}…)`}
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => revoke.mutate(k.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
|
||||
{revealedToken ? (
|
||||
<div className="flex flex-col gap-3 border-t p-4">
|
||||
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
|
||||
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{revealedToken}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyToken}>
|
||||
<Copy /> Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setRevealedToken(null)}>
|
||||
Готово
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
@@ -13,9 +13,10 @@ function Tabs({
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
orientation={orientation}
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,7 +25,7 @@ function Tabs({
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -58,10 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user