feat(admin): журналы входов и изменений с IP/UA и сессиями
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m57s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip;
SSO handoff и revoke refresh-сессий; улучшены audit-карточки.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-31 03:12:39 +07:00
co-authored by Cursor
parent 8b1203f5e9
commit cc38bf06f8
24 changed files with 1431 additions and 69 deletions
+15 -3
View File
@@ -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}
/>
+16
View File
@@ -123,6 +123,22 @@ export async function ssoHandoffReturnTo(returnTo: string): Promise<boolean> {
const allowlist = await ensureReturnToAllowlist()
if (!isReturnToAllowed(returnTo, allowlist)) return false
const issued = await reissueAccessToken()
try {
const token = getToken()
if (token) {
void fetch('/api/v1/auth/sso-access', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ return_to: returnTo }),
keepalive: true,
}).catch(() => undefined)
}
} catch {
/* best-effort audit */
}
window.location.href = buildSsoRedirectUrl(
returnTo,
issued.access_token,
+11
View File
@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import type {
AuditKind,
AuditLogEntry,
AuditSettings,
AuditSourceApp,
@@ -11,29 +12,39 @@ export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
export function auditListQueryKey(opts?: {
userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp | 'all'
kind?: AuditKind
}) {
return [
...auditQueryKey,
'list',
opts?.userId ?? null,
opts?.actorEmail ?? null,
opts?.sourceApp ?? 'all',
opts?.kind ?? null,
] as const
}
export function auditQueryOptions(opts?: {
userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp
kind?: AuditKind
limit?: number
}) {
const params = new URLSearchParams()
params.set('limit', String(opts?.limit ?? 200))
if (opts?.userId) params.set('user_id', opts.userId)
if (opts?.actorEmail) params.set('actor_email', opts.actorEmail)
if (opts?.sourceApp) params.set('source_app', opts.sourceApp)
if (opts?.kind) params.set('kind', opts.kind)
return queryOptions({
queryKey: auditListQueryKey({
userId: opts?.userId,
actorEmail: opts?.actorEmail,
sourceApp: opts?.sourceApp ?? 'all',
kind: opts?.kind,
}),
queryFn: () =>
api.get<AuditLogEntry[]>(`/api/v1/admin/audit?${params.toString()}`),
+22
View File
@@ -0,0 +1,22 @@
import { queryOptions } from '@tanstack/react-query'
import type { AdminSession } from '@authportal/shared'
import { api } from '@/lib/api-client'
export const sessionsQueryKey = ['admin', 'sessions'] as const
export function sessionsListQueryKey(userId?: string) {
return [...sessionsQueryKey, 'list', userId ?? null] as const
}
export function sessionsQueryOptions(opts?: { userId?: string }) {
const params = new URLSearchParams()
if (opts?.userId) params.set('user_id', opts.userId)
const qs = params.toString()
return queryOptions({
queryKey: sessionsListQueryKey(opts?.userId),
queryFn: () =>
api.get<AdminSession[]>(
`/api/v1/admin/sessions${qs ? `?${qs}` : ''}`,
),
})
}
+21
View File
@@ -17,6 +17,7 @@ import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
const IndexRoute = IndexRouteImport.update({
@@ -58,6 +59,11 @@ const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({
path: '/audit',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({
id: '/logins',
path: '/logins',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
'/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -92,6 +100,7 @@ export interface FileRoutesById {
'/_auth/apps': typeof AuthAppsRoute
'/_auth/admin/apps': typeof AuthAdminAppsRoute
'/_auth/admin/audit': typeof AuthAdminAuditRoute
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
'/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
@@ -104,6 +113,7 @@ export interface FileRouteTypes {
| '/apps'
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin/'
| '/admin/users/$userId'
fileRoutesByTo: FileRoutesByTo
@@ -113,6 +123,7 @@ export interface FileRouteTypes {
| '/apps'
| '/admin/apps'
| '/admin/audit'
| '/admin/logins'
| '/admin'
| '/admin/users/$userId'
id:
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
| '/_auth/apps'
| '/_auth/admin/apps'
| '/_auth/admin/audit'
| '/_auth/admin/logins'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
fileRoutesById: FileRoutesById
@@ -192,6 +204,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminAuditRouteImport
parentRoute: typeof AuthAdminRoute
}
'/_auth/admin/logins': {
id: '/_auth/admin/logins'
path: '/logins'
fullPath: '/admin/logins'
preLoaderRoute: typeof AuthAdminLoginsRouteImport
parentRoute: typeof AuthAdminRoute
}
'/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId'
path: '/users/$userId'
@@ -205,6 +224,7 @@ declare module '@tanstack/react-router' {
interface AuthAdminRouteChildren {
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
}
@@ -212,6 +232,7 @@ interface AuthAdminRouteChildren {
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
AuthAdminAppsRoute: AuthAdminAppsRoute,
AuthAdminAuditRoute: AuthAdminAuditRoute,
AuthAdminLoginsRoute: AuthAdminLoginsRoute,
AuthAdminIndexRoute: AuthAdminIndexRoute,
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
}
+1 -1
View File
@@ -30,7 +30,7 @@ function AdminAuditPage() {
isLoading,
error,
refetch,
} = useQuery(auditQueryOptions())
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const { data: settings } = useQuery(auditSettingsQueryOptions)
const saveMutation = useMutation({
+547
View File
@@ -0,0 +1,547 @@
/**
* Admin logins journal — history DataGrid + active sessions.
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/stats-12
* Sessions DNA: https://reui.io/preview/base/solution-users-2
*/
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type ColumnDef,
type PaginationState,
type SortingState,
useReactTable,
} from '@tanstack/react-table'
import { toast } from 'sonner'
import type { AdminSession, AuditLogEntry } from '@authportal/shared'
import {
KeyRoundIcon,
LogInIcon,
ShieldAlertIcon,
Trash2Icon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import {
actionMeta,
detailString,
formatEventTime,
matchesRange,
sourceAppLabel,
type AuditRange,
} from '@/components/reui-kit/audit-log-helpers'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { auditQueryOptions } from '@/queries/audit'
import {
sessionsListQueryKey,
sessionsQueryOptions,
} from '@/queries/sessions'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { RANGE_OPTIONS } from '@/components/reui-kit/audit-log-helpers'
export const Route = createFileRoute('/_auth/admin/logins')({
component: AdminLoginsPage,
})
type ResultFilter = 'all' | 'success' | 'failed'
function resultVariant(action: string) {
if (action === 'auth.login_failed') return 'warning-outline' as const
if (action === 'auth.sso_handoff') return 'info-outline' as const
return 'success-outline' as const
}
function AdminLoginsPage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'history' | 'sessions'>('history')
const [resultFilter, setResultFilter] = useState<ResultFilter>('all')
const [range, setRange] = useState<AuditRange>('7d')
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
])
const {
data: entries = [],
isLoading,
error,
refetch,
} = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
const {
data: sessions = [],
isLoading: sessionsLoading,
error: sessionsError,
refetch: refetchSessions,
} = useQuery(sessionsQueryOptions())
const revokeMutation = useMutation({
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: sessionsListQueryKey() })
toast.success('Сессия отозвана')
},
onError: (err) => {
toast.error(
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
)
},
})
const filtered = useMemo(() => {
return entries.filter((e) => {
if (!matchesRange(e, range)) return false
if (resultFilter === 'success') {
return e.action === 'auth.login' || e.action === 'auth.sso_handoff'
}
if (resultFilter === 'failed') return e.action === 'auth.login_failed'
return true
})
}, [entries, range, resultFilter])
const kpi = useMemo(() => {
const day = entries.filter((e) => matchesRange(e, '24h'))
const ok = day.filter(
(e) => e.action === 'auth.login' || e.action === 'auth.sso_handoff',
).length
const fail = day.filter((e) => e.action === 'auth.login_failed').length
const ips = new Set(
day.map((e) => e.ip).filter((ip): ip is string => Boolean(ip)),
)
return { ok, fail, ips: ips.size, sessions: sessions.length }
}, [entries, sessions.length])
const historyColumns = useMemo<ColumnDef<AuditLogEntry>[]>(
() => [
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataGridColumnHeader title="Время" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.created_at)}
</span>
),
size: 140,
},
{
id: 'result',
accessorFn: (r) => r.action,
header: ({ column }) => (
<DataGridColumnHeader title="Результат" column={column} />
),
cell: ({ row }) => (
<Badge variant={resultVariant(row.original.action)} size="sm">
{actionMeta(row.original.action).label}
</Badge>
),
size: 140,
},
{
accessorKey: 'actor_email',
header: ({ column }) => (
<DataGridColumnHeader title="Логин" column={column} />
),
cell: ({ row }) => (
<span className="truncate text-sm font-medium">
{row.original.actor_email ?? '—'}
</span>
),
size: 200,
},
{
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader title="IP" column={column} />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.ip ?? '—'}
</span>
),
size: 130,
},
{
id: 'ua',
accessorFn: (r) => detailString(r.details, 'user_agent') ?? '',
header: ({ column }) => (
<DataGridColumnHeader title="User-Agent" column={column} />
),
cell: ({ row }) => {
const ua = detailString(row.original.details, 'user_agent')
return (
<span className="text-muted-foreground line-clamp-1 max-w-[220px] text-xs" title={ua ?? undefined}>
{ua ?? '—'}
</span>
)
},
size: 220,
},
{
id: 'app',
accessorFn: (r) =>
detailString(r.details, 'target_app') ?? r.source_app,
header: ({ column }) => (
<DataGridColumnHeader title="Приложение" column={column} />
),
cell: ({ row }) => {
const app =
detailString(row.original.details, 'target_app') ??
row.original.source_app
return (
<Badge variant="outline" size="sm">
{sourceAppLabel(app)}
</Badge>
)
},
size: 120,
},
{
id: 'reason',
accessorFn: (r) => detailString(r.details, 'reason') ?? '',
header: ({ column }) => (
<DataGridColumnHeader title="Причина" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{detailString(row.original.details, 'reason') ?? '—'}
</span>
),
size: 120,
},
],
[],
)
const historyTable = useReactTable({
data: filtered,
columns: historyColumns,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const sessionColumns = useMemo<ColumnDef<AdminSession>[]>(
() => [
{
accessorKey: 'email',
header: ({ column }) => (
<DataGridColumnHeader title="Пользователь" column={column} />
),
cell: ({ row }) => (
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{row.original.name}
</span>
<span className="text-muted-foreground truncate text-xs">
{row.original.email}
</span>
</div>
),
size: 200,
},
{
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader title="IP" column={column} />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">
{row.original.ip ?? '—'}
</span>
),
size: 130,
},
{
accessorKey: 'user_agent',
header: ({ column }) => (
<DataGridColumnHeader title="User-Agent" column={column} />
),
cell: ({ row }) => (
<span
className="text-muted-foreground line-clamp-1 max-w-[240px] text-xs"
title={row.original.user_agent ?? undefined}
>
{row.original.user_agent ?? '—'}
</span>
),
size: 240,
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataGridColumnHeader title="Создана" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.created_at)}
</span>
),
size: 140,
},
{
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader title="Истекает" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm tabular-nums">
{formatEventTime(row.original.expires_at)}
</span>
),
size: 140,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate(row.original.id)}
>
<Trash2Icon aria-hidden="true" />
Отозвать
</Button>
),
size: 120,
},
],
[revokeMutation],
)
const [sessionPagination, setSessionPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const sessionsTable = useReactTable({
data: sessions,
columns: sessionColumns,
state: { pagination: sessionPagination },
onPaginationChange: setSessionPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
return (
<PageShell>
<div className="flex flex-col gap-6">
<KpiStatGrid
items={[
{
id: 'ok',
label: 'Успешные (24ч)',
value: kpi.ok,
icon: <LogInIcon />,
iconClassName: 'text-success',
hint: 'Вход и SSO',
},
{
id: 'fail',
label: 'Неудачи (24ч)',
value: kpi.fail,
icon: <ShieldAlertIcon />,
iconClassName: 'text-warning',
variant: kpi.fail > 0 ? 'warning' : 'default',
},
{
id: 'ips',
label: 'Уникальные IP (24ч)',
value: kpi.ips,
icon: <KeyRoundIcon />,
},
{
id: 'sessions',
label: 'Активные сессии',
value: kpi.sessions,
icon: <KeyRoundIcon />,
onSelect: () => setTab('sessions'),
},
]}
/>
<Tabs
value={tab}
onValueChange={(v) => {
if (v === 'history' || v === 'sessions') setTab(v)
}}
>
<TabsList
variant="line"
className="h-10! w-full justify-start gap-6 border-b"
>
<TabsTrigger value="history" className="px-1 text-sm after:-bottom-px!">
История
</TabsTrigger>
<TabsTrigger value="sessions" className="px-1 text-sm after:-bottom-px!">
Активные сессии
<span className="bg-muted text-muted-foreground ms-2 rounded-md px-1.5 text-xs tabular-nums">
{sessions.length}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
{tab === 'history' ? (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Tabs
value={resultFilter}
onValueChange={(v) => {
if (v === 'all' || v === 'success' || v === 'failed') {
setResultFilter(v)
}
}}
>
<TabsList variant="line" className="h-auto gap-4 border-0">
<TabsTrigger value="all" className="px-0 pb-1 text-sm">
Все
</TabsTrigger>
<TabsTrigger value="success" className="px-0 pb-1 text-sm">
Успешные
</TabsTrigger>
<TabsTrigger value="failed" className="px-0 pb-1 text-sm">
Неудачи
</TabsTrigger>
</TabsList>
</Tabs>
<Select
value={range}
onValueChange={(value) => {
if (value) setRange(value as AuditRange)
}}
items={[...RANGE_OPTIONS]}
>
<SelectTrigger size="sm" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{RANGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{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 ? (
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
table={historyTable}
recordCount={filtered.length}
tableLayout={{ dense: true }}
>
<Frame stacked dense>
<FrameHeader>
<FrameTitle>Журнал входов</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)}
</div>
) : sessionsError ? (
<div className="flex flex-col gap-2">
<p className="text-destructive text-sm">
{sessionsError instanceof ApiError
? sessionsError.message
: 'Ошибка загрузки'}
</p>
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => refetchSessions()}
>
Повторить
</Button>
</div>
) : sessionsLoading ? (
<Skeleton className="h-64 w-full" />
) : (
<DataGrid
table={sessionsTable}
recordCount={sessions.length}
tableLayout={{ dense: true }}
>
<Frame stacked dense>
<FrameHeader>
<FrameTitle>Активные сессии</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
)}
</div>
</PageShell>
)
}