316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { CircleCheckIcon, GlobeIcon, MonitorSmartphoneIcon, ShieldAlertIcon } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import type { AdminSession, AuditLogEntry } from '@authportal/shared'
|
||
import { actionMeta, detailString, exportAuditCsv, matchesRange, sourceAppLabel, type AuditRange } from '@/components/reui-kit/audit-log-helpers'
|
||
import { ExplainBanner } from '@/components/portal/explain-banner'
|
||
import { CodeBlock, KvList, PageHead, PageTab, PageTabsList, PortalSheet, Segmented, StatusBadge } from '@/components/portal/bits'
|
||
import { fmtWhen } from '@/components/portal/format'
|
||
import { kpi } from '@/components/portal/kpi'
|
||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||
import type { Tone } from '@/components/portal/tone'
|
||
import { Button } from '@authportal/ui/components/button'
|
||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||
import { Tabs, TabsContent } from '@authportal/ui/components/tabs'
|
||
import { api, ApiError } from '@/lib/api-client'
|
||
import { auditQueryOptions } from '@/queries/audit'
|
||
import { sessionsListQueryKey, sessionsQueryOptions } from '@/queries/sessions'
|
||
|
||
export const Route = createFileRoute('/_auth/admin/logins')({
|
||
component: AdminLoginsPage,
|
||
})
|
||
|
||
const PERIODS: Array<{ label: string; value: AuditRange }> = [
|
||
{ label: '24 ч', value: '24h' },
|
||
{ label: '7 д', value: '7d' },
|
||
{ label: '30 д', value: '30d' },
|
||
{ label: '90 д', value: '90d' },
|
||
]
|
||
|
||
type LoginRow = {
|
||
id: string
|
||
login: string
|
||
client: string
|
||
tone: Tone
|
||
status: string
|
||
time: string
|
||
ip: string
|
||
app: string
|
||
reason: string
|
||
entry: AuditLogEntry
|
||
}
|
||
|
||
type SessionRow = {
|
||
id: string
|
||
user: string
|
||
note: string
|
||
ipClient: string
|
||
created: string
|
||
expires: string
|
||
}
|
||
|
||
const FAILED = 'auth.login_failed'
|
||
const isOk = (a: string) => a === 'auth.login' || a === 'auth.sso_handoff'
|
||
|
||
function toneOf(action: string): Tone {
|
||
if (action === FAILED) return 'er'
|
||
if (action === 'auth.sso_handoff') return 'in'
|
||
if (action === 'auth.logout') return 'wn'
|
||
return 'ok'
|
||
}
|
||
|
||
function shortUa(ua: string | null | undefined) {
|
||
if (!ua) return '—'
|
||
const m = /(Edg|Chrome|Firefox|Safari)\/(\d+)/.exec(ua)
|
||
const os = /Windows|Mac OS X|Linux|iPhone|Android/.exec(ua)?.[0]?.replace('Mac OS X', 'macOS')
|
||
return [m ? `${m[1] === 'Edg' ? 'Edge' : m[1]} ${m[2]}` : ua.slice(0, 24), os].filter(Boolean).join(' · ')
|
||
}
|
||
|
||
function AdminLoginsPage() {
|
||
const queryClient = useQueryClient()
|
||
const navigate = useNavigate()
|
||
const [range, setRange] = useState(0)
|
||
const [event, setEvent] = useState<LoginRow | null>(null)
|
||
|
||
const entriesQ = useQuery(auditQueryOptions({ kind: 'logins', limit: 500 }))
|
||
const sessionsQ = useQuery(sessionsQueryOptions())
|
||
const entries = useMemo(() => entriesQ.data ?? [], [entriesQ.data])
|
||
const sessions = useMemo(() => sessionsQ.data ?? [], [sessionsQ.data])
|
||
const period = PERIODS[range]!.value
|
||
|
||
const revoke = 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 rows = useMemo<LoginRow[]>(
|
||
() =>
|
||
entries
|
||
.filter((e) => matchesRange(e, period))
|
||
.map((e) => ({
|
||
id: e.id,
|
||
login: e.actor_email ?? '—',
|
||
client: shortUa(detailString(e.details, 'user_agent')),
|
||
tone: toneOf(e.action),
|
||
status: actionMeta(e.action).label,
|
||
time: fmtWhen(e.created_at),
|
||
ip: e.ip ?? '—',
|
||
app: sourceAppLabel(detailString(e.details, 'target_app') ?? e.source_app),
|
||
reason: detailString(e.details, 'reason') ?? '—',
|
||
entry: e,
|
||
})),
|
||
[entries, period],
|
||
)
|
||
|
||
const sessionRows = useMemo<SessionRow[]>(
|
||
() =>
|
||
sessions.map((s: AdminSession) => ({
|
||
id: s.id,
|
||
user: s.email,
|
||
note: s.name,
|
||
ipClient: `${s.ip ?? '—'} · ${shortUa(s.user_agent)}`,
|
||
created: fmtWhen(s.created_at),
|
||
expires: fmtWhen(s.expires_at),
|
||
})),
|
||
[sessions],
|
||
)
|
||
|
||
const stats = useMemo(() => {
|
||
const day = entries.filter((e) => matchesRange(e, '24h'))
|
||
return {
|
||
ok: day.filter((e) => isOk(e.action)).length,
|
||
fail: day.filter((e) => e.action === FAILED).length,
|
||
ips: new Set(day.map((e) => e.ip).filter(Boolean)).size,
|
||
users: new Set(sessions.map((s) => s.user_id)).size,
|
||
}
|
||
}, [entries, sessions])
|
||
|
||
// Серия неудач: 5 и больше с одного IP за последние 10 минут (по времени самой свежей записи журнала).
|
||
const burst = useMemo(() => {
|
||
const latest = entries.reduce((m, e) => Math.max(m, new Date(e.created_at).getTime()), 0)
|
||
const byIp = new Map<string, AuditLogEntry[]>()
|
||
for (const e of entries) {
|
||
if (e.action !== FAILED || !e.ip) continue
|
||
if (latest - new Date(e.created_at).getTime() > 10 * 60 * 1000) continue
|
||
byIp.set(e.ip, [...(byIp.get(e.ip) ?? []), e])
|
||
}
|
||
const top = [...byIp.entries()].sort((a, b) => b[1].length - a[1].length)[0]
|
||
return top && top[1].length >= 5 ? { ip: top[0], n: top[1].length, login: top[1][0]!.actor_email ?? '—' } : null
|
||
}, [entries])
|
||
|
||
const loginCols = useMemo<GridColumn<LoginRow>[]>(
|
||
() => [
|
||
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
|
||
{ id: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
|
||
{ id: 'time', title: 'Время', size: 150, sortValue: (r) => r.entry.created_at, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.time}</span> },
|
||
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[12.5px]">{r.ip}</span> },
|
||
{ id: 'app', title: 'Приложение', size: 160, cell: (r) => <span className="font-mono text-[12.5px]">{r.app}</span> },
|
||
{ id: 'reason', title: 'Причина', size: 200, cell: (r) => <span className="font-mono text-[12.5px]">{r.reason}</span> },
|
||
],
|
||
[],
|
||
)
|
||
|
||
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
|
||
() => [
|
||
{ id: 'user', title: 'Пользователь', size: 260, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot="ok" title={r.user} sub={r.note} /> },
|
||
{ id: 'status', title: 'Статус', size: 110, cell: () => <StatusBadge tone="ok">Активна</StatusBadge> },
|
||
{ id: 'ip', title: 'IP · клиент', size: 280, cell: (r) => <span className="font-mono text-[12.5px]">{r.ipClient}</span> },
|
||
{ id: 'created', title: 'Создана', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.created}</span> },
|
||
{ id: 'exp', title: 'Истекает', size: 150, cell: (r) => <span className="font-mono text-[12.5px] whitespace-nowrap">{r.expires}</span> },
|
||
],
|
||
[],
|
||
)
|
||
|
||
const loading = entriesQ.isLoading || sessionsQ.isLoading
|
||
const failedQuery = entriesQ.isError || sessionsQ.isError
|
||
|
||
return (
|
||
<>
|
||
<PageHead
|
||
title="Входы"
|
||
sub="Журнал входов и активные сессии · портал и SSO в приложения"
|
||
actions={
|
||
<Button
|
||
variant="outline"
|
||
disabled={rows.length === 0}
|
||
onClick={() => {
|
||
exportAuditCsv(rows.map((r) => r.entry))
|
||
toast.success(`CSV сформирован: ${rows.length} записей`)
|
||
}}
|
||
>
|
||
Экспорт CSV
|
||
</Button>
|
||
}
|
||
/>
|
||
{burst ? (
|
||
<ExplainBanner
|
||
tone="er"
|
||
title={`Подозрительная серия неудач: ${burst.login}.`}
|
||
text={`${burst.n} неудачных входов с ${burst.ip} за 10 минут. Действия: «Показать», отключить пользователя в разделе «Пользователи».`}
|
||
>
|
||
{burst.n} неудачных входов с {burst.ip} за 10 минут. Проверьте журнал и при необходимости отключите учётную запись или заблокируйте адрес.
|
||
</ExplainBanner>
|
||
) : null}
|
||
{failedQuery ? (
|
||
<div role="alert" className="border-destructive/40 bg-destructive/10 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 text-sm">
|
||
<span>Не удалось загрузить журнал. Повторите запрос.</span>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
void entriesQ.refetch()
|
||
void sessionsQ.refetch()
|
||
}}
|
||
>
|
||
Повторить
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
<Segmented label="Период" options={PERIODS.map((p) => p.label)} value={range} onChange={setRange} />
|
||
<KpiStrip
|
||
aria-label="Показатели"
|
||
items={[
|
||
kpi('ok', 'Успешные (24ч)', String(stats.ok), { icon: CircleCheckIcon, tone: 'ok', badge: 'входы и SSO' }),
|
||
kpi('fail', 'Неудачи (24ч)', String(stats.fail), { icon: ShieldAlertIcon, tone: stats.fail ? 'er' : 'neutral', badge: 'неверный пароль и др.' }),
|
||
kpi('ip', 'Уникальные IP (24ч)', String(stats.ips), { icon: GlobeIcon, badge: 'по журналу' }),
|
||
kpi('sess', 'Активные сессии', String(sessions.length), { icon: MonitorSmartphoneIcon, badge: `${stats.users} пользователей` }),
|
||
]}
|
||
/>
|
||
<Tabs defaultValue="hist" className="min-w-0">
|
||
<PageTabsList>
|
||
<PageTab value="hist" label="История" count={rows.length} />
|
||
<PageTab value="sess" label="Активные сессии" count={sessions.length} />
|
||
</PageTabsList>
|
||
<TabsContent value="hist" className="flex min-w-0 flex-col gap-4">
|
||
{loading ? (
|
||
<Skeleton className="h-72 w-full rounded-xl" />
|
||
) : (
|
||
<SectionGrid
|
||
title="Журнал входов"
|
||
description="Поиск по логину и IP · виды по результату · период выбирается выше"
|
||
rows={rows}
|
||
columns={loginCols}
|
||
getRowId={(r) => r.id}
|
||
searchText={(r) => `${r.login} ${r.ip} ${r.app}`}
|
||
searchPlaceholder="Логин или IP"
|
||
selectable={false}
|
||
views={[
|
||
{ label: 'Все', match: () => true },
|
||
{ label: 'Успешные', match: (r) => r.tone === 'ok' || r.tone === 'in' },
|
||
{ label: 'Неудачные', match: (r) => r.tone === 'er' || r.tone === 'wn' },
|
||
]}
|
||
onRowClick={setEvent}
|
||
emptyMessage="За выбранный период входов не было."
|
||
/>
|
||
)}
|
||
</TabsContent>
|
||
<TabsContent value="sess" className="flex min-w-0 flex-col gap-4">
|
||
{loading ? (
|
||
<Skeleton className="h-48 w-full rounded-xl" />
|
||
) : (
|
||
<SectionGrid
|
||
title="Активные сессии"
|
||
description="Токены, действующие сейчас · завершение сессии выкидывает пользователя из портала и приложений"
|
||
rows={sessionRows}
|
||
columns={sessionCols}
|
||
getRowId={(r) => r.id}
|
||
selectable={false}
|
||
emptyMessage="Нет активных сессий."
|
||
rowMenu={(r) => ({ toggleLabel: 'Завершить сессию', onToggle: () => revoke.mutate(r.id) })}
|
||
/>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<PortalSheet
|
||
open={event !== null}
|
||
onOpenChange={(o) => !o && setEvent(null)}
|
||
title="Событие входа"
|
||
description="Детали записи журнала"
|
||
footer={
|
||
<>
|
||
<Button variant="outline" onClick={() => setEvent(null)}>
|
||
Закрыть
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
disabled={!event?.entry.actor_user_id}
|
||
onClick={() => {
|
||
const id = event?.entry.actor_user_id
|
||
setEvent(null)
|
||
if (id) void navigate({ to: '/admin/users/$userId', params: { userId: id } })
|
||
}}
|
||
>
|
||
Открыть пользователя
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{event ? (
|
||
<>
|
||
<div>
|
||
<StatusBadge tone={event.tone}>{event.status}</StatusBadge>
|
||
</div>
|
||
<KvList
|
||
items={[
|
||
['Логин', event.login],
|
||
['IP', event.ip],
|
||
['Клиент', event.client],
|
||
['Приложение', event.app],
|
||
['Причина', event.reason],
|
||
]}
|
||
/>
|
||
<CodeBlock label="Событие">{`action: ${event.entry.action}\nsource_app: ${event.entry.source_app}\nseverity: ${event.entry.severity}\nip: ${event.ip}\nat: ${event.entry.created_at}`}</CodeBlock>
|
||
</>
|
||
) : null}
|
||
</PortalSheet>
|
||
</>
|
||
)
|
||
}
|