quality / changes (push) Successful in 9s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 23s
quality / web (push) Successful in 1m22s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 3m31s
ResourcePage и FrameDataGrid получили раскладку досок: заголовок Frame с поиском и фильтрами, линейные виды со счётчиками, подвал «Показано N из M». Страница модулей — KPI-полоса, кнопка создания в шапке, вкладка. Операции: действия применения и перезагрузки BIRD в шапке, баннер при ошибках задач. Сеть: баннер пиров на одобрение. Кнопки шапок — стандартной высоты. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
import { createFileRoute, Link } from '@tanstack/react-router'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { KeyRound, MonitorIcon, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-react'
|
||
import { useMemo } from 'react'
|
||
|
||
import { Button } from '@evobgp/ui/components/button'
|
||
import {
|
||
Item,
|
||
ItemContent,
|
||
ItemDescription,
|
||
ItemGroup,
|
||
ItemMedia,
|
||
ItemTitle,
|
||
} from '@evobgp/ui/components/item'
|
||
|
||
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||
import { PageHeader } from '@/components/page-header'
|
||
import { Badge } from '@/components/reui/badge'
|
||
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||
import { SettingsCard } from '@/components/settings/settings-card'
|
||
import { sessionCanManageApiKeys } from '@/lib/auth'
|
||
import { authSessionQueryOptions } from '@/queries/auth'
|
||
import { apiKeysQueryOptions } from '@/queries/api-keys'
|
||
|
||
export const Route = createFileRoute('/_auth/access')({
|
||
component: AccessComponent,
|
||
})
|
||
|
||
function AccessComponent() {
|
||
const sessionQuery = useQuery(authSessionQueryOptions())
|
||
const session = sessionQuery.data ?? null
|
||
const canManageKeys = sessionCanManageApiKeys(session)
|
||
|
||
const keysQuery = useQuery({
|
||
...apiKeysQueryOptions(),
|
||
enabled: canManageKeys,
|
||
})
|
||
|
||
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: KpiStatItem[] = useMemo(
|
||
() => [
|
||
{
|
||
id: 'keys-total',
|
||
label: 'Всего ключей',
|
||
value: keys.length,
|
||
icon: <KeyRound className="size-4" />,
|
||
footer: (
|
||
<Badge variant="primary-light" size="sm">
|
||
в арендаторе
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
id: 'keys-active',
|
||
label: 'Активных',
|
||
value: activeCount,
|
||
icon: <ShieldCheck className="size-4" />,
|
||
footer: (
|
||
<Badge variant="success-light" size="sm">
|
||
не отозваны
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
id: 'keys-revoked',
|
||
label: 'Отозванных',
|
||
value: revokedCount,
|
||
icon: <ShieldOff className="size-4" />,
|
||
variant: revokedCount > 0 ? 'warning' : 'default',
|
||
footer: (
|
||
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
|
||
отозваны
|
||
</Badge>
|
||
),
|
||
},
|
||
],
|
||
[keys.length, activeCount, revokedCount],
|
||
)
|
||
|
||
function refetchAll() {
|
||
void sessionQuery.refetch()
|
||
if (canManageKeys) void keysQuery.refetch()
|
||
}
|
||
|
||
const sessionKindLabel =
|
||
session?.kind === 'jwt' ? 'JWT портала' : session?.kind === 'apikey' ? 'API-ключ' : null
|
||
|
||
const sessionAccessLabel = (() => {
|
||
if (!session) return null
|
||
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
|
||
if (session.is_admin) return 'Администратор (портал)'
|
||
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
|
||
return session.permissions?.length
|
||
? session.permissions.slice(0, 3).join(', ')
|
||
: 'без права access:admin'
|
||
}
|
||
return session.role || '—'
|
||
})()
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-col gap-6">
|
||
<PageHeader
|
||
title="Права доступа"
|
||
description="API-ключи плоскости управления и текущая сессия."
|
||
actions={
|
||
canManageKeys ? (
|
||
<Button variant="outline" onClick={refetchAll} disabled={refreshing}>
|
||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
||
Обновить
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
|
||
{session ? (
|
||
<SettingsCard
|
||
title="Текущая сессия"
|
||
description="Арендатор и права текущей сессии (API-ключ или JWT портала)."
|
||
>
|
||
<ItemGroup className="gap-0">
|
||
<Item className="min-h-0 min-w-0 items-start gap-4 px-5 py-3.5">
|
||
<ItemMedia className="self-start">
|
||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||
<MonitorIcon aria-hidden="true" />
|
||
</Item>
|
||
</ItemMedia>
|
||
<ItemContent className="min-w-0 gap-3">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<ItemTitle className="leading-5">Этот браузер</ItemTitle>
|
||
{sessionKindLabel ? (
|
||
<Badge variant="info-light" size="sm">
|
||
{sessionKindLabel}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<ItemDescription className="flex min-w-0 flex-col gap-1 leading-5">
|
||
<span className="break-all">
|
||
Арендатор:{' '}
|
||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||
</span>
|
||
<span className="break-words">Доступ: {sessionAccessLabel}</span>
|
||
{session.email ? <span className="break-all">Email: {session.email}</span> : null}
|
||
</ItemDescription>
|
||
</ItemContent>
|
||
</Item>
|
||
</ItemGroup>
|
||
</SettingsCard>
|
||
) : (
|
||
<SettingsCard title="Текущая сессия">
|
||
<p className="text-muted-foreground px-5 py-4 text-sm">
|
||
Не удалось определить сессию. Укажите токен в{' '}
|
||
<Link
|
||
to="/settings"
|
||
search={{ tab: 'ui' }}
|
||
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="text-destructive mt-2 block">{sessionQuery.error.message}</span>
|
||
) : null}
|
||
</p>
|
||
</SettingsCard>
|
||
)}
|
||
|
||
{canManageKeys ? (
|
||
<>
|
||
<KpiStatGrid items={kpiItems} isLoading={keysQuery.isLoading} skeletonCount={3} />
|
||
<AccessApiKeysCard
|
||
items={keys}
|
||
isLoading={keysQuery.isLoading}
|
||
isError={keysQuery.isError}
|
||
error={keysQuery.error}
|
||
onRetry={() => keysQuery.refetch()}
|
||
/>
|
||
</>
|
||
) : session ? (
|
||
<SettingsCard title="API-ключи">
|
||
<p className="text-muted-foreground px-5 py-4 text-sm break-words">
|
||
Управление API-ключами доступно роли <strong>оператор</strong> (API-ключ) или JWT портала
|
||
с правом администратора / <code className="text-xs">bgp:access:admin</code>.
|
||
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
|
||
</p>
|
||
</SettingsCard>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|