feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:28 +07:00
co-authored by Cursor
parent 26fc8253c3
commit 57e34ff5e9
27 changed files with 1983 additions and 639 deletions
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
import type { AuditLogEntry, AuditSeverity, AuditSourceApp } from '@authportal/shared'
import {
KeyRoundIcon,
LogInIcon,
@@ -32,6 +32,18 @@ export const RANGE_OPTIONS = [
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
export const SOURCE_APP_OPTIONS: {
value: AuditSourceApp | 'all'
label: string
}[] = [
{ value: 'all', label: 'Все приложения' },
{ value: 'portal', label: 'Auth Portal' },
{ value: 'vps', label: 'VPS Tracker' },
{ value: 'cfdm', label: 'CFDM' },
{ value: 'bgp', label: 'EvoBGP' },
{ value: 'fw', label: 'EvoFirewall' },
]
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
info: 'success-outline',
warning: 'warning-outline',
@@ -97,6 +109,14 @@ export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
return actionMeta(entry.action).filter === filter
}
export function matchesSourceApp(
entry: AuditLogEntry,
source: AuditSourceApp | 'all',
) {
if (source === 'all') return true
return entry.source_app === source
}
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
if (range === 'all') return true
const ms =
@@ -4,7 +4,7 @@
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
*/
import { useMemo, useState } from 'react'
import type { AuditLogEntry } from '@authportal/shared'
import type { AuditLogEntry, AuditSourceApp } from '@authportal/shared'
import { toast } from 'sonner'
import {
CalendarIcon,
@@ -56,6 +56,7 @@ import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
AUDIT_FILTER_OPTIONS,
RANGE_OPTIONS,
SOURCE_APP_OPTIONS,
actionMeta,
exportAuditCsv,
formatEventTime,
@@ -63,6 +64,7 @@ import {
initials,
matchesFilter,
matchesRange,
matchesSourceApp,
severityDotClass,
severityLabel,
severityVariant,
@@ -227,81 +229,128 @@ function EventRow({
export function AuditLogTimeline({
entries,
totalCount,
compact = false,
lockedUserId,
sourceAppFilter,
onSourceAppFilterChange,
}: {
entries: AuditLogEntry[]
totalCount: number
compact?: boolean
lockedUserId?: string
sourceAppFilter?: AuditSourceApp | 'all'
onSourceAppFilterChange?: (v: AuditSourceApp | 'all') => void
}) {
const [filter, setFilter] = useState<AuditFilterId>('all')
const [range, setRange] = useState<AuditRange>('7d')
const [range, setRange] = useState<AuditRange>(compact ? 'all' : '7d')
const [localSource, setLocalSource] = useState<AuditSourceApp | 'all'>(
sourceAppFilter ?? 'all',
)
const source = sourceAppFilter ?? localSource
const setSource = onSourceAppFilterChange ?? setLocalSource
const visible = useMemo(
() =>
entries.filter(
(e) => matchesFilter(e, filter) && matchesRange(e, range),
(e) =>
matchesFilter(e, filter) &&
matchesRange(e, range) &&
matchesSourceApp(e, source),
),
[entries, filter, range],
[entries, filter, range, source],
)
const days = useMemo(() => groupByDay(visible), [visible])
return (
<section className="w-full" aria-labelledby="audit-log-title">
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1
id="audit-log-title"
className="text-xl font-semibold tracking-tight"
>
Журнал аудита
</h1>
<p className="text-muted-foreground text-sm leading-5">
{totalCount} событий · показано {visible.length}
</p>
</div>
<div className={cn('flex flex-col gap-4', compact ? 'mb-4' : 'mb-6')}>
{!compact ? (
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h1
id="audit-log-title"
className="text-xl font-semibold tracking-tight"
>
Журнал аудита
</h1>
<p className="text-muted-foreground text-sm leading-5">
{totalCount} событий · показано {visible.length}
{lockedUserId ? ' · пользователь' : ''}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-44">
<CalendarIcon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Select
value={source}
onValueChange={(value) => {
if (value) setSource(value as AuditSourceApp | 'all')
}}
items={[...SOURCE_APP_OPTIONS]}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{SOURCE_APP_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
exportAuditCsv(visible)
toast.success('CSV экспортирован', {
description: `${visible.length} событий`,
})
}}
>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-44">
<CalendarIcon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" alignItemWithTrigger={false}>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
exportAuditCsv(visible)
toast.success('CSV экспортирован', {
description: `${visible.length} событий`,
})
}}
>
<DownloadIcon aria-hidden="true" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
</div>
</div>
</div>
) : (
<p
id="audit-log-title"
className="text-muted-foreground text-sm leading-5"
>
{visible.length} из {totalCount} событий
</p>
)}
<Tabs
value={filter}
@@ -0,0 +1,172 @@
/**
* Create user Sheet — auth-portal admin.
* Preview: https://reui.io/preview/base/solution-users-1
*/
import { useEffect, useState } from 'react'
import type { CreateUserRequest } from '@authportal/shared'
import { XIcon } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@authportal/ui/components/button'
import { Checkbox } from '@authportal/ui/components/checkbox'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function CreateUserSheet({
open,
onOpenChange,
pending,
error,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
error: string | null
onSubmit: (values: CreateUserRequest) => void
}) {
const [isAdmin, setIsAdmin] = useState(false)
useEffect(() => {
if (!open) setIsAdmin(false)
}, [open])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(24rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Новый пользователь
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Создание учётной записи портала
</SheetDescription>
</SheetHeader>
<form
id="create-user-form"
className="flex min-h-0 flex-1 flex-col"
onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
onSubmit({
email: String(fd.get('email') ?? ''),
name: String(fd.get('name') ?? ''),
password: String(fd.get('password') ?? ''),
is_admin: isAdmin,
apps: [],
permissions: [],
})
}}
>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<FieldGroup>
<Field>
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
<Input
id="create-name"
name="name"
autoComplete="name"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-email">Email</FieldLabel>
<Input
id="create-email"
name="email"
type="email"
autoComplete="email"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
<Input
id="create-password"
name="password"
type="password"
autoComplete="new-password"
minLength={6}
required
/>
</Field>
<Field orientation="horizontal">
<Checkbox
id="create-is-admin"
checked={isAdmin}
onCheckedChange={(v) => setIsAdmin(v === true)}
/>
<FieldLabel htmlFor="create-is-admin" className="font-normal">
Администратор портала
</FieldLabel>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive" className="mt-5">
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button
type="submit"
form="create-user-form"
className="min-w-0 flex-1"
disabled={pending}
>
{pending ? 'Создание…' : 'Создать'}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -13,3 +13,6 @@ export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export type { ResourcePageProps, ResourcePageTab } from './resource-page'
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
export { UserAccessSheet } from './user-access-sheet'
export { UserAuditSheet } from './user-audit-sheet'
export { CreateUserSheet } from './create-user-sheet'
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
@@ -0,0 +1,109 @@
/**
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet.
* Preview: https://reui.io/preview/base/solution-users-1
* Timeline: https://reui.io/preview/base/solution-users-6
*/
import { useQuery } from '@tanstack/react-query'
import type { AdminUser } from '@authportal/shared'
import { XIcon } from 'lucide-react'
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { auditQueryOptions } from '@/queries/audit'
import { ApiError } from '@/lib/api-client'
import { Button } from '@authportal/ui/components/button'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import { Skeleton } from '@authportal/ui/components/skeleton'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
export function UserAuditSheet({
user,
open,
onOpenChange,
}: {
user: AdminUser | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const userId = user?.id
const { data: entries = [], isLoading, error, refetch } = useQuery({
...auditQueryOptions({ userId, limit: 200 }),
enabled: open && Boolean(userId),
})
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Журнал: {user?.name ?? '…'}
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
{user?.email ?? 'События пользователя (актор или цель)'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{error ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{error instanceof ApiError
? error.message
: 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetch()}
>
Повторить
</Button>
</div>
) : isLoading ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (
<AuditLogTimeline
entries={entries}
totalCount={entries.length}
compact
lockedUserId={userId}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
)
}