feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
AppWindowIcon,
|
||||
HistoryIcon,
|
||||
LayoutGridIcon,
|
||||
LogInIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
@@ -66,7 +67,8 @@ export function AppSidebar() {
|
||||
isActive={
|
||||
isActive(pathname, '/admin', false) &&
|
||||
!pathname.startsWith('/admin/apps') &&
|
||||
!pathname.startsWith('/admin/audit')
|
||||
!pathname.startsWith('/admin/audit') &&
|
||||
!pathname.startsWith('/admin/logins')
|
||||
}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
@@ -76,12 +78,22 @@ export function AppSidebar() {
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Журнал"
|
||||
tooltip="Входы"
|
||||
isActive={isActive(pathname, '/admin/logins', false)}
|
||||
render={<Link to="/admin/logins" />}
|
||||
>
|
||||
<LogInIcon className="size-4" />
|
||||
<span>Входы</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Изменения"
|
||||
isActive={isActive(pathname, '/admin/audit', false)}
|
||||
render={<Link to="/admin/audit" />}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
<span>Журнал</span>
|
||||
<span>Изменения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -16,8 +16,11 @@ function breadcrumbs(pathname: string) {
|
||||
if (pathname.startsWith('/admin/apps')) {
|
||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/logins')) {
|
||||
return [{ label: 'Журнал входов', href: '/admin/logins' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/audit')) {
|
||||
return [{ label: 'Журнал аудита', href: '/admin/audit' }]
|
||||
return [{ label: 'Журнал изменений', href: '/admin/audit' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/users/')) {
|
||||
return [
|
||||
|
||||
@@ -656,6 +656,26 @@ function createAdminUserColumns(handlers: {
|
||||
skeleton: <Skeleton className="h-4 w-28" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_ip',
|
||||
id: 'lastIp',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Последний IP" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{row.original.last_login_ip ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Последний IP',
|
||||
skeleton: <Skeleton className="h-4 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
|
||||
@@ -73,6 +73,11 @@ const ACTION_META: Record<
|
||||
filter: 'auth',
|
||||
},
|
||||
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
|
||||
'auth.sso_handoff': {
|
||||
label: 'SSO',
|
||||
icon: KeyRoundIcon,
|
||||
filter: 'auth',
|
||||
},
|
||||
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
|
||||
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
|
||||
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
|
||||
@@ -117,6 +122,22 @@ export function matchesSourceApp(
|
||||
return entry.source_app === source
|
||||
}
|
||||
|
||||
export function sourceAppLabel(app: AuditSourceApp | string): string {
|
||||
return (
|
||||
SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app)
|
||||
)
|
||||
}
|
||||
|
||||
export function detailString(
|
||||
details: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!details) return null
|
||||
const v = details[key]
|
||||
if (typeof v === 'string' && v.trim()) return v
|
||||
return null
|
||||
}
|
||||
|
||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||
if (range === 'all') return true
|
||||
const ms =
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
RANGE_OPTIONS,
|
||||
SOURCE_APP_OPTIONS,
|
||||
actionMeta,
|
||||
detailString,
|
||||
exportAuditCsv,
|
||||
formatEventTime,
|
||||
groupByDay,
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
sourceAppLabel,
|
||||
type AuditFilterId,
|
||||
type AuditRange,
|
||||
} from './audit-log-helpers'
|
||||
@@ -108,6 +110,10 @@ function EventRow({
|
||||
const meta = actionMeta(event.action)
|
||||
const Icon = meta.icon
|
||||
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
|
||||
const userAgent = detailString(event.details, 'user_agent')
|
||||
const targetApp =
|
||||
detailString(event.details, 'target_app') ?? event.source_app
|
||||
const reason = detailString(event.details, 'reason')
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
|
||||
@@ -127,6 +133,9 @@ function EventRow({
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<Badge variant="outline" size="sm">
|
||||
{sourceAppLabel(event.source_app)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(event.created_at)}
|
||||
</span>
|
||||
@@ -149,16 +158,25 @@ function EventRow({
|
||||
aria-label={`Подробности: ${meta.label}`}
|
||||
>
|
||||
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{initials(event.actor_name, event.actor_email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{actorName}
|
||||
{event.summary ? ` — ${event.summary}` : null}
|
||||
<span className="text-foreground min-w-0 truncate text-sm font-medium">
|
||||
{event.summary || actorName}
|
||||
</span>
|
||||
{event.ip ? (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{event.ip}
|
||||
</span>
|
||||
) : null}
|
||||
{targetApp && targetApp !== 'portal' ? (
|
||||
<Badge variant="info-outline" size="sm">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
|
||||
@@ -170,23 +188,36 @@ function EventRow({
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<DetailRow label="Цель">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.target_type
|
||||
? `${event.target_type}${event.target_id ? `: ${event.target_id}` : ''}`
|
||||
: '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Актор">
|
||||
<DetailRow label="Логин">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor_email ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="IP">
|
||||
<span className="text-foreground truncate font-medium tabular-nums">
|
||||
<span className="text-foreground truncate font-medium tabular-nums font-mono text-xs">
|
||||
{event.ip ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="User-Agent">
|
||||
<span
|
||||
className="text-foreground line-clamp-2 text-xs"
|
||||
title={userAgent ?? undefined}
|
||||
>
|
||||
{userAgent ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Приложение">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{reason ? (
|
||||
<DetailRow label="Причина">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{reason}
|
||||
</span>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
<DetailRow label="Действие">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.action}
|
||||
@@ -204,18 +235,34 @@ function EventRow({
|
||||
<Badge variant="outline" className="gap-1.5 font-mono">
|
||||
{event.id.slice(0, 8)}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.id)
|
||||
toast.success('ID скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
Копировать ID
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{userAgent ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(userAgent)
|
||||
toast.success('User-Agent скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
UA
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.id)
|
||||
toast.success('ID скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
ID
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
@@ -272,7 +319,7 @@ export function AuditLogTimeline({
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал аудита
|
||||
Журнал изменений
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1 · https://reui.io/preview/base/solution-users-2
|
||||
* Timeline: https://reui.io/preview/base/solution-users-6
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { formatEventTime } from '@/components/reui-kit/audit-log-helpers'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
sessionsListQueryKey,
|
||||
sessionsQueryOptions,
|
||||
} from '@/queries/sessions'
|
||||
import { api, 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 { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -32,12 +40,71 @@ export function UserAuditSheet({
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const userId = user?.id
|
||||
const { data: entries = [], isLoading, error, refetch } = useQuery({
|
||||
...auditQueryOptions({ userId, limit: 200 }),
|
||||
enabled: open && Boolean(userId),
|
||||
const email = user?.email
|
||||
const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins')
|
||||
|
||||
const loginsQuery = useQuery({
|
||||
...auditQueryOptions({
|
||||
userId,
|
||||
actorEmail: email,
|
||||
kind: 'logins',
|
||||
limit: 200,
|
||||
}),
|
||||
enabled: open && Boolean(userId) && tab === 'logins',
|
||||
})
|
||||
|
||||
const changesQuery = useQuery({
|
||||
...auditQueryOptions({ userId, kind: 'changes', limit: 200 }),
|
||||
enabled: open && Boolean(userId) && tab === 'changes',
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
...sessionsQueryOptions({ userId }),
|
||||
enabled: open && Boolean(userId) && tab === 'sessions',
|
||||
})
|
||||
|
||||
const revokeAll = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<{ revoked: number }>(
|
||||
`/api/v1/admin/users/${userId}/sessions/revoke-all`,
|
||||
),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionsListQueryKey(userId),
|
||||
})
|
||||
toast.success(`Отозвано сессий: ${data.revoked}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось отозвать сессии',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const revokeOne = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionsListQueryKey(userId),
|
||||
})
|
||||
toast.success('Сессия отозвана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const active =
|
||||
tab === 'logins'
|
||||
? loginsQuery
|
||||
: tab === 'changes'
|
||||
? changesQuery
|
||||
: sessionsQuery
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -65,38 +132,138 @@ export function UserAuditSheet({
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
|
||||
{user?.email ?? 'События пользователя (актор или цель)'}
|
||||
{user?.email ?? 'События пользователя'}
|
||||
{user?.last_login_ip
|
||||
? ` · последний IP ${user.last_login_ip}`
|
||||
: ''}
|
||||
</SheetDescription>
|
||||
<div className="border-b px-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'logins' || v === 'changes' || v === 'sessions') {
|
||||
setTab(v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-10! w-full justify-start gap-5"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="logins"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Входы
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="changes"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Изменения
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sessions"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Сессии
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
{active.error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError
|
||||
? error.message
|
||||
{active.error instanceof ApiError
|
||||
? active.error.message
|
||||
: 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
onClick={() => active.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
) : active.isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : tab === 'sessions' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
revokeAll.isPending ||
|
||||
(sessionsQuery.data?.length ?? 0) === 0
|
||||
}
|
||||
onClick={() => revokeAll.mutate()}
|
||||
>
|
||||
Отозвать все
|
||||
</Button>
|
||||
</div>
|
||||
{(sessionsQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет активных сессий
|
||||
</p>
|
||||
) : (
|
||||
(sessionsQuery.data ?? []).map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border-border flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{s.ip ?? '—'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={revokeOne.isPending}
|
||||
onClick={() => revokeOne.mutate(s.id)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-2 text-xs"
|
||||
title={s.user_agent ?? undefined}
|
||||
>
|
||||
{s.user_agent ?? '—'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(s.created_at)} · до{' '}
|
||||
{formatEventTime(s.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTimeline
|
||||
entries={entries}
|
||||
totalCount={entries.length}
|
||||
entries={
|
||||
(tab === 'logins' ? loginsQuery.data : changesQuery.data) ??
|
||||
[]
|
||||
}
|
||||
totalCount={
|
||||
(
|
||||
(tab === 'logins'
|
||||
? loginsQuery.data
|
||||
: changesQuery.data) ?? []
|
||||
).length
|
||||
}
|
||||
compact
|
||||
lockedUserId={userId}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user