Compare commits

...
4 Commits
Author SHA1 Message Date
Denozordec 53b3c49612 refactor(settings): simplify settings query options and remove tenant dependency
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 1m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m5s
Updated the settings query options to eliminate the tenant ID parameter, streamlining the settings retrieval process. Adjusted the TenantSettingsComponent to reflect this change, ensuring it now queries settings without relying on tenant-specific data. This refactor enhances code clarity and reduces complexity in the settings management flow.
2026-07-06 23:28:14 +07:00
Denozordec db79820df0 feat(auth): introduce demo token support and enhance API token handling
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 57s
CI / go (push) Successful in 1m9s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m57s
Added a local demo token for development purposes and improved the API token management by normalizing input tokens. Updated the authentication flow to utilize the new token handling, allowing for better session management and user experience. Enhanced the settings component to support the demo token and provide clear instructions for its use in local development.
2026-07-06 22:55:30 +07:00
Denozordec a902a4270d fix(api-keys): enhance API key management with new mutations and UI updates
CI / changes (push) Successful in 14s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 59s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m47s
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.
2026-07-06 20:32:17 +07:00
Denozordec 3a3e6db018 fix(tabs): update Tabs component for orientation handling and improve class names
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 45s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m15s
Enhanced the Tabs component to support orientation prop, ensuring proper layout for both horizontal and vertical orientations. Updated class names for better clarity and consistency in styling. Additionally, modified the daemon PID and timestamps in .codegraph/daemon.pid for synchronization.
2026-07-06 18:45:44 +07:00
17 changed files with 781 additions and 233 deletions
+2 -2
View File
@@ -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>
)
}
+19
View File
@@ -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')
}
+22 -2
View File
@@ -11,6 +11,9 @@ import type {
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
/** Локальный demo-токен (operator) при включённом demo-seed — см. docs/access.md */
export const DEV_API_TOKEN = 'dev'
export type Problem = {
type?: string
title?: string
@@ -18,14 +21,31 @@ export type Problem = {
detail?: string
}
/** Убирает пробелы и опциональный префикс Bearer (UI часто вставляет «Bearer dev»). */
export function normalizeApiToken(raw: string): string {
let t = raw.trim()
if (/^bearer\s+/i.test(t)) {
t = t.replace(/^bearer\s+/i, '').trim()
}
return t
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
return normalized || null
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
if (!token) {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
return
}
const normalized = normalizeApiToken(token)
if (normalized) window.localStorage.setItem(TOKEN_STORAGE_KEY, normalized)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
+50 -3
View File
@@ -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 : 'Не удалось ротировать'),
})
}
+8 -4
View File
@@ -1,10 +1,14 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
export const Route = createFileRoute('/_auth')({
beforeLoad: () => {
const token =
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
if (!token) {
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings' })
}
},
+102 -190
View File
@@ -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,80 @@ 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 ноды). Полный токен
показывается один раз при создании и ротации. Токен браузера в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>
; для локальной разработки с demo-seed подойдёт <code className="text-xs">dev</code>{' '}
(роль operator).
</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 +112,46 @@ function AccessComponent() {
</div>
</CardContent>
</Card>
) : null}
) : (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</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>
)
}
+61 -15
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
@@ -14,10 +16,10 @@ import {
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authSessionQueryOptions } from '@/queries/auth'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Save } from 'lucide-react'
import { Info, Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
@@ -32,7 +34,14 @@ const THEME_SELECT_ITEMS = [
] as const
function SettingsComponent() {
const { data: session } = useQuery(authSessionQueryOptions())
const navigate = useNavigate()
const qc = useQueryClient()
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
...authSessionQueryOptions(),
enabled: Boolean(
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
),
})
const { theme, setTheme } = useTheme()
const [token, setTokenValue] = useState('')
@@ -41,10 +50,23 @@ function SettingsComponent() {
setTokenValue(t)
}, [])
function saveTokenHandler() {
const t = token.trim()
setToken(t || null)
async function applyToken(raw: string) {
const normalized = normalizeApiToken(raw)
setToken(normalized || null)
setTokenValue(normalized)
await qc.invalidateQueries({ queryKey: authKeys.all })
toast.success('Токен сохранён')
if (normalized) {
void navigate({ to: '/dashboard' })
}
}
function saveTokenHandler() {
void applyToken(token)
}
function useDevToken() {
void applyToken(DEV_API_TOKEN)
}
return (
@@ -54,11 +76,21 @@ function SettingsComponent() {
description="Параметры интерфейса и подключения браузера к API."
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Локальная разработка</AlertTitle>
<AlertDescription>
При включённом demo-seed API принимает токен <code className="text-xs">dev</code> (роль{' '}
<code className="text-xs">operator</code>). Вводите только значение токена, без префикса{' '}
<code className="text-xs">Bearer</code> он добавляется автоматически.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant в
Токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
@@ -71,19 +103,33 @@ function SettingsComponent() {
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="Bearer …"
placeholder="dev или API-ключ"
/>
</div>
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<div className="flex flex-wrap gap-2">
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
<Button type="button" variant="outline" onClick={useDevToken}>
Использовать dev
</Button>
</div>
{session ? (
<p className="text-xs text-muted-foreground">
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
<code className="font-mono">{session.role}</code>.
</p>
) : null}
{sessionError ? (
<p className="text-xs text-destructive">
{sessionQueryError instanceof Error
? sessionQueryError.message
: 'Не удалось проверить сессию'}
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API.
</p>
) : null}
</CardContent>
</Card>
@@ -35,6 +35,7 @@ import {
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsKeys,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
@@ -93,7 +94,7 @@ function TenantSettingsComponent() {
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: ['settings'] })
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
+1 -1
View File
@@ -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"}
+2
View File
@@ -50,6 +50,8 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
Если в store доступен демо-tenant (`DemoIDs`, обычно `EVOBGP_SEED_DEMO` не равен `0`), заголовок **`Authorization: Bearer dev`** даёт роль **`operator`** для этого tenant. **Не зависит** от `EVOBGP_DEV_INSECURE`.
Без demo-tenant токен `dev` может быть задан в `EVOBGP_API_KEYS` (break-glass).
**Запрещено** в продакшене: не оставляйте demo-seed с известным токеном `dev` на боевых данных. Переменная `EVOBGP_DEV_INSECURE` в текущей версии **не влияет** на аутентификацию (оставлена в compose для совместимости; не включайте в production — см. SEC-02 в инженерных правилах).
### PostgreSQL monitoring и maintenance (control plane)
+27 -11
View File
@@ -63,27 +63,43 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if raw == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
matched, ok := s.keyResolver.Lookup(raw)
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw, APIKeyID: matched.keyID}
if matched.keyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(matched.keyID)
if a.APIKeyID != "" {
go func(id string) { _ = s.store.TouchAPIKeyLastUsed(id) }(a.APIKeyID)
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
}
// resolveAuth maps a bearer token to tenant identity.
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
if raw == "dev" {
if a, ok := s.devAuth(); ok {
return a, true
}
if rec, ok := s.keyResolver.Lookup(raw); ok {
return authFromKeyRecord(raw, rec), true
}
return Auth{}, false
}
rec, ok := s.keyResolver.Lookup(raw)
if !ok {
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestBearerDevGetSettings(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/settings", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestBearerDevPrefersDemoTenantOverEnvKey(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
demoTenant, _, _, _, _ := srv.Store().DemoIDs()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != demoTenant {
t.Fatalf("tenant_id=%q want demo tenant %q (env=%q)", got, demoTenant, otherTenant)
}
}
func TestBearerDevFallsBackToEnvKeyWithoutDemo(t *testing.T) {
srv, err := New(Options{SeedDemo: false, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
otherTenant := "00000000-0000-4000-8000-000000000001"
mustSetTestAPIKeys(t, srv, "dev|"+otherTenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/session", nil)
req.Header.Set("Authorization", "Bearer dev")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("session status=%d body=%s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
got, _ := body["tenant_id"].(string)
if got != otherTenant {
t.Fatalf("tenant_id=%q want env key tenant %q", got, otherTenant)
}
}
+6
View File
@@ -589,6 +589,9 @@ func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
}
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
if _, err := uuid.Parse(tenantID); err != nil {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
if err != nil {
@@ -610,6 +613,9 @@ func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
}
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
if _, err := uuid.Parse(tenantID); err != nil {
return store.ErrInvalidInput
}
if patch == nil {
return nil
}
+5 -4
View File
@@ -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}