quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s
Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках. Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски. Удалены settings-7 и самописные DataGridSection/toolbar. Co-authored-by: Cursor <[email protected]>
171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
import { useMemo, useState, type ReactNode } from 'react'
|
|
import { KeyRound, RefreshCw, Trash2 } from 'lucide-react'
|
|
|
|
import { Button } from '@evobgp/ui/components/button'
|
|
import { CategoryBadge } from '@/components/category-badge'
|
|
import { DataGridMutedCell, DataGridNameCell } from '@/components/data-grid-cell'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
|
import {
|
|
ResourcePage,
|
|
createSearchFilterField,
|
|
createTextFilterQuery,
|
|
type DataGridColumnDef,
|
|
} from '@/components/reui-kit'
|
|
import { formatApiKeyDate } from '@/lib/access/api-key-labels'
|
|
import type { ApiKey } from '@/types/api'
|
|
|
|
const filterFields: FilterField[] = [
|
|
createSearchFilterField('search', 'Поиск', 'Поиск API-ключей…'),
|
|
]
|
|
|
|
export function AccessApiKeysGrid({
|
|
items,
|
|
isLoading = false,
|
|
onRotate,
|
|
onRevoke,
|
|
rotatePending = false,
|
|
revokePending = false,
|
|
actions,
|
|
}: {
|
|
items: ApiKey[]
|
|
isLoading?: boolean
|
|
onRotate: (id: string) => void
|
|
onRevoke: (id: string) => void
|
|
rotatePending?: boolean
|
|
revokePending?: boolean
|
|
actions?: ReactNode
|
|
}) {
|
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
|
createTextFilterQuery('search'),
|
|
)
|
|
|
|
const columns = useMemo<DataGridColumnDef<ApiKey>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: 'name',
|
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
|
cell: ({ row }) => (
|
|
<DataGridNameCell
|
|
icon={KeyRound}
|
|
title={row.original.name}
|
|
subtitle={`${row.original.prefix}…`}
|
|
/>
|
|
),
|
|
minSize: 160,
|
|
meta: { headerTitle: 'Имя' },
|
|
},
|
|
{
|
|
accessorKey: 'role',
|
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
|
cell: ({ row }) => (
|
|
<CategoryBadge className="font-mono text-xs">{row.original.role}</CategoryBadge>
|
|
),
|
|
meta: { headerTitle: 'Роль' },
|
|
},
|
|
{
|
|
id: 'status',
|
|
enableSorting: false,
|
|
header: 'Статус',
|
|
cell: ({ row }) =>
|
|
row.original.revoked_at ? (
|
|
<StatusBadge status="error" label="отозван" />
|
|
) : (
|
|
<StatusBadge status="active" label="активен" />
|
|
),
|
|
meta: { headerTitle: 'Статус' },
|
|
},
|
|
{
|
|
id: 'expires_at',
|
|
accessorFn: (row) => row.expires_at ?? '',
|
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Истекает" />,
|
|
cell: ({ row }) => (
|
|
<DataGridMutedCell>{formatApiKeyDate(row.original.expires_at)}</DataGridMutedCell>
|
|
),
|
|
meta: { headerTitle: 'Истекает' },
|
|
},
|
|
{
|
|
id: 'last_used_at',
|
|
accessorFn: (row) => row.last_used_at ?? '',
|
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Использован" />,
|
|
cell: ({ row }) => (
|
|
<DataGridMutedCell>{formatApiKeyDate(row.original.last_used_at)}</DataGridMutedCell>
|
|
),
|
|
meta: { headerTitle: 'Использован' },
|
|
},
|
|
{
|
|
id: 'actions',
|
|
enableSorting: false,
|
|
header: () => null,
|
|
cell: ({ row }) => {
|
|
const k = row.original
|
|
return (
|
|
<div className="flex shrink-0 gap-1">
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
type="button"
|
|
title="Ротировать"
|
|
disabled={!!k.revoked_at || rotatePending}
|
|
>
|
|
<RefreshCw className="size-3.5" />
|
|
</Button>
|
|
}
|
|
title="Ротировать ключ?"
|
|
description="Старый токен перестанет работать сразу."
|
|
confirmLabel="Ротировать"
|
|
onConfirm={() => onRotate(k.id)}
|
|
/>
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
type="button"
|
|
className="text-destructive"
|
|
disabled={!!k.revoked_at || revokePending}
|
|
title="Отозвать"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
}
|
|
title="Отозвать API-ключ?"
|
|
description={`${k.name} (${k.prefix}…)`}
|
|
confirmLabel="Отозвать"
|
|
destructive
|
|
onConfirm={() => onRevoke(k.id)}
|
|
/>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
],
|
|
[onRevoke, onRotate, revokePending, rotatePending],
|
|
)
|
|
|
|
return (
|
|
<ResourcePage
|
|
title="API-ключи"
|
|
description="Полный токен показывается только при создании и ротации."
|
|
filterFields={filterFields}
|
|
filterQuery={filterQuery}
|
|
onFilterQueryChange={setFilterQuery}
|
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('search'))}
|
|
getFilterFieldValue={(row) =>
|
|
`${row.name} ${row.role} ${row.prefix} ${row.revoked_at ? 'отозван' : 'активен'}`
|
|
}
|
|
columns={columns}
|
|
data={items}
|
|
getRowId={(row) => row.id}
|
|
isLoading={isLoading}
|
|
primaryAction={actions}
|
|
pinLastColumn
|
|
emptyState={{ title: 'Нет ключей' }}
|
|
/>
|
|
)
|
|
}
|