Files
auth-portal/apps/web/src/routes/_auth.admin.logins.tsx
T

316 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)
}