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 <cursoragent@cursor.com>
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
+1
View File
@@ -92,6 +92,7 @@ export async function buildApp(opts: {
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
})
app.decorate('config', config)
+43
View File
@@ -0,0 +1,43 @@
import type { AuditSourceApp } from '@authportal/shared'
/** Resolve SSO target app from return_to URL host/path. */
export function targetAppFromReturnTo(
returnTo: string | undefined | null,
): AuditSourceApp {
if (!returnTo) return 'portal'
let host = ''
let path = ''
try {
const u = new URL(returnTo)
host = u.hostname.toLowerCase()
path = u.pathname.toLowerCase()
} catch {
return 'portal'
}
const hay = `${host} ${path}`
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
if (
/\bcfdm\b/.test(hay) ||
host.includes('cfdm') ||
host.includes('domain')
) {
return 'cfdm'
}
if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp'
if (
/\bfw\b/.test(hay) ||
host.includes('firewall') ||
host.includes('evofw')
) {
return 'fw'
}
return 'portal'
}
export function clientUserAgent(
headers: Record<string, string | string[] | undefined>,
): string | null {
const ua = headers['user-agent']
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
return null
}
+65
View File
@@ -8,7 +8,10 @@ import {
getUserByEmail,
getUserById,
getUserPermissions,
listActiveSessions,
listUsers,
revokeAllSessionsForUser,
revokeSessionById,
setAppSwitcherConfig,
setUserAccess,
updateUser,
@@ -46,6 +49,7 @@ function mapUser(
apps,
permissions,
last_login_at: user.lastLoginAt ?? null,
last_login_ip: user.lastLoginIp ?? null,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
@@ -296,4 +300,65 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
return result
})
app.get('/api/v1/admin/sessions', async (request) => {
const userId = (request.query as { user_id?: string }).user_id
return listActiveSessions(app.db, { userId }).map((s) => ({
id: s.id,
user_id: s.userId,
email: s.email,
name: s.name,
ip: s.ip,
user_agent: s.userAgent,
created_at: s.createdAt,
expires_at: s.expiresAt,
}))
})
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/sessions/:id',
async (request, reply) => {
const ok = revokeSessionById(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
})
}
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'session',
targetId: request.params.id,
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
details: { source: 'admin_revoke' },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/sessions/revoke-all',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const revoked = revokeAllSessionsForUser(app.db, user.id)
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
details: { revoked, source: 'admin_revoke_all' },
ip: clientIp(request),
})
return { revoked }
},
)
}
+2
View File
@@ -30,7 +30,9 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
action: q.action,
severity: q.severity,
userId: q.user_id,
actorEmail: q.actor_email,
sourceApp: q.source_app,
kind: q.kind,
limit: q.limit,
})
})
+80 -16
View File
@@ -6,6 +6,7 @@ import {
appsMetaFromSwitcher,
loginRequestSchema,
publicAppSwitcherConfig,
ssoAccessRequestSchema,
} from '@authportal/shared'
import {
createRefreshSession,
@@ -19,6 +20,7 @@ import {
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
const REFRESH_COOKIE = 'refresh_token'
@@ -39,8 +41,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const { email, password } = parsed.data
const { email, password, return_to: returnTo } = parsed.data
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
safeAudit(app, {
@@ -49,7 +53,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
actorEmail: email.toLowerCase(),
targetType: 'session',
summary: `Неудачный вход: ${email}`,
details: { reason: !user ? 'unknown_user' : 'disabled' },
details: {
reason: !user ? 'unknown_user' : 'disabled',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
@@ -68,7 +77,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session',
targetId: user.id,
summary: `Неудачный вход: ${user.email}`,
details: { reason: 'bad_password' },
details: {
reason: 'bad_password',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
@@ -82,8 +96,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
touchLastLogin(app.db, user.id)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
ip,
userAgent,
})
touchLastLogin(app.db, user.id, ip)
reply.header(
'Set-Cookie',
@@ -99,6 +116,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session',
targetId: user.id,
summary: `Вход: ${user.email}`,
details: {
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
@@ -128,6 +150,51 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
/** Record SSO handoff to a connected app (already authenticated). */
app.post(
'/api/v1/auth/sso-access',
{
onRequest: requireAuth,
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (request, reply) => {
const parsed = ssoAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const returnTo = parsed.data.return_to
const targetApp = targetAppFromReturnTo(returnTo)
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
safeAudit(app, {
action: 'auth.sso_handoff',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `SSO: ${user.email}${targetApp}`,
details: {
return_to: returnTo,
target_app: targetApp,
user_agent: userAgent,
},
ip,
})
return { ok: true, target_app: targetApp }
},
)
app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
@@ -165,6 +232,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session',
targetId: actorUserId,
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
details: { user_agent: clientUserAgent(request.headers) },
ip: clientIp(request),
})
@@ -218,15 +286,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
app.get(
'/api/v1/catalog',
{ onRequest: requireAuth },
async () => {
const switcher = getAppSwitcherConfig(app.db)
return {
apps: appsMetaFromSwitcher(switcher),
permissions: PERMISSION_CATALOG,
}
},
)
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
const switcher = getAppSwitcherConfig(app.db)
return {
apps: appsMetaFromSwitcher(switcher),
permissions: PERMISSION_CATALOG,
}
})
}
+140
View File
@@ -183,4 +183,144 @@ describe('audit log API', () => {
await app.close()
})
it('records login with IP, UA and last_login_ip', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: {
'x-forwarded-for': '203.0.113.10',
'user-agent': 'VitestBrowser/1.0',
},
payload: {
email: 'admin@test.local',
password: 'adminpass',
return_to: 'https://vps.example.test/dashboard',
},
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; last_login_ip: string | null }[]
).find((u) => u.email === 'admin@test.local')
expect(admin?.last_login_ip).toBe('203.0.113.10')
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
ip: string | null
details: Record<string, unknown> | null
}[]
const loginEvt = entries.find((e) => e.action === 'auth.login')
expect(loginEvt?.ip).toBe('203.0.113.10')
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
expect(loginEvt?.details?.target_app).toBe('vps')
await app.close()
})
it('records failed login with email and IP', async () => {
const app = await buildTestApp()
const fail = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: 'nobody@test.local', password: 'wrong' },
})
expect(fail.statusCode).toBe(401)
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
actor_email: string | null
ip: string | null
}[]
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
expect(entries[0]?.actor_email).toBe('nobody@test.local')
expect(entries[0]?.ip).toBe('198.51.100.7')
await app.close()
})
it('records sso-access and separates kind=changes', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sso = await app.inject({
method: 'POST',
url: '/api/v1/auth/sso-access',
headers: { authorization: `Bearer ${token}` },
payload: { return_to: 'https://bgp.example.test/' },
})
expect(sso.statusCode).toBe(200)
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
const logins = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const loginEntries = logins.json() as { action: string }[]
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
const changes = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=changes',
headers: { authorization: `Bearer ${token}` },
})
const changeEntries = changes.json() as { action: string }[]
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
await app.close()
})
it('lists and revokes refresh sessions', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sessions = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(sessions.statusCode).toBe(200)
const rows = sessions.json() as { id: string; user_id: string }[]
expect(rows.length).toBeGreaterThanOrEqual(1)
const sessionId = rows[0]!.id
const revoke = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/sessions/${sessionId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(revoke.statusCode).toBe(200)
const after = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
).toBe(false)
await app.close()
})
})
+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>
)
}
+20 -2
View File
@@ -1,7 +1,8 @@
import { and, desc, eq, lt, or, sql } from 'drizzle-orm'
import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import {
DEFAULT_AUDIT_RETENTION_DAYS,
type AuditKind,
type AuditLogEntry,
type AuditSeverity,
type AuditSourceApp,
@@ -99,7 +100,9 @@ export function listAudit(
action?: string
severity?: AuditSeverity
userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp
kind?: AuditKind
limit?: number
} = {},
): AuditLogEntry[] {
@@ -108,7 +111,22 @@ export function listAudit(
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
if (opts.userId) {
if (opts.kind === 'logins') {
conditions.push(like(auditLog.action, 'auth.%'))
} else if (opts.kind === 'changes') {
conditions.push(notLike(auditLog.action, 'auth.%'))
}
if (opts.actorEmail && opts.userId) {
conditions.push(
or(
eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()),
eq(auditLog.actorUserId, opts.userId),
eq(auditLog.targetId, opts.userId),
)!,
)
} else if (opts.actorEmail) {
conditions.push(eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()))
} else if (opts.userId) {
conditions.push(
or(
eq(auditLog.actorUserId, opts.userId),
+16
View File
@@ -38,6 +38,7 @@ export function migrateSchema(sqlite: Sqlite): void {
is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
last_login_at TEXT,
last_login_ip TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -58,6 +59,8 @@ export function migrateSchema(sqlite: Sqlite): void {
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL
);
@@ -110,6 +113,19 @@ export function migrateSchema(sqlite: Sqlite): void {
if (!userCols.some((c) => c.name === 'last_login_at')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`)
}
if (!userCols.some((c) => c.name === 'last_login_ip')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_ip TEXT`)
}
const sessionCols = sqlite
.prepare(`PRAGMA table_info(refresh_sessions)`)
.all() as Array<{ name: string }>
if (!sessionCols.some((c) => c.name === 'ip')) {
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN ip TEXT`)
}
if (!sessionCols.some((c) => c.name === 'user_agent')) {
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN user_agent TEXT`)
}
const auditCols = sqlite
.prepare(`PRAGMA table_info(audit_log)`)
+3
View File
@@ -8,6 +8,7 @@ export const users = sqliteTable('users', {
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
lastLoginAt: text('last_login_at'),
lastLoginIp: text('last_login_ip'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
})
@@ -34,6 +35,8 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
tokenHash: text('token_hash').notNull(),
expiresAt: text('expires_at').notNull(),
revokedAt: text('revoked_at'),
ip: text('ip'),
userAgent: text('user_agent'),
createdAt: text('created_at').notNull(),
})
+95 -4
View File
@@ -130,10 +130,18 @@ export function setUserAccess(
.run()
}
export function touchLastLogin(db: AppDb, userId: string): void {
export function touchLastLogin(
db: AppDb,
userId: string,
ip?: string | null,
): void {
const now = new Date().toISOString()
db.update(users)
.set({ lastLoginAt: now, updatedAt: now })
.set({
lastLoginAt: now,
lastLoginIp: ip ?? null,
updatedAt: now,
})
.where(eq(users.id, userId))
.run()
}
@@ -143,17 +151,22 @@ export function createRefreshSession(
userId: string,
rawToken: string,
expiresAt: Date,
): void {
meta?: { ip?: string | null; userAgent?: string | null },
): string {
const id = randomUUID()
db.insert(refreshSessions)
.values({
id: randomUUID(),
id,
userId,
tokenHash: hashToken(rawToken),
expiresAt: expiresAt.toISOString(),
revokedAt: null,
ip: meta?.ip ?? null,
userAgent: meta?.userAgent ?? null,
createdAt: new Date().toISOString(),
})
.run()
return id
}
export function revokeRefreshSession(db: AppDb, rawToken: string): void {
@@ -163,3 +176,81 @@ export function revokeRefreshSession(db: AppDb, rawToken: string): void {
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
.run()
}
export type ActiveSessionRow = {
id: string
userId: string
email: string
name: string
ip: string | null
userAgent: string | null
createdAt: string
expiresAt: string
}
export function listActiveSessions(
db: AppDb,
opts: { userId?: string } = {},
): ActiveSessionRow[] {
const now = new Date().toISOString()
const rows = db
.select({
id: refreshSessions.id,
userId: refreshSessions.userId,
email: users.email,
name: users.name,
ip: refreshSessions.ip,
userAgent: refreshSessions.userAgent,
createdAt: refreshSessions.createdAt,
expiresAt: refreshSessions.expiresAt,
revokedAt: refreshSessions.revokedAt,
})
.from(refreshSessions)
.innerJoin(users, eq(refreshSessions.userId, users.id))
.all()
return rows
.filter((r) => {
if (r.revokedAt) return false
if (r.expiresAt < now) return false
if (opts.userId && r.userId !== opts.userId) return false
return true
})
.map((r) => ({
id: r.id,
userId: r.userId,
email: r.email,
name: r.name,
ip: r.ip,
userAgent: r.userAgent,
createdAt: r.createdAt,
expiresAt: r.expiresAt,
}))
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}
export function revokeSessionById(db: AppDb, sessionId: string): boolean {
const row = db
.select()
.from(refreshSessions)
.where(eq(refreshSessions.id, sessionId))
.get()
if (!row || row.revokedAt) return false
db.update(refreshSessions)
.set({ revokedAt: new Date().toISOString() })
.where(eq(refreshSessions.id, sessionId))
.run()
return true
}
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
const now = new Date().toISOString()
const active = listActiveSessions(db, { userId })
for (const s of active) {
db.update(refreshSessions)
.set({ revokedAt: now })
.where(eq(refreshSessions.id, s.id))
.run()
}
return active.length
}
+14
View File
@@ -29,6 +29,7 @@ export const AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.sso_handoff',
'user.create',
'user.update',
'user.delete',
@@ -40,6 +41,17 @@ export const AUDIT_ACTIONS = [
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
export const AUDIT_KINDS = ['logins', 'changes'] as const
export type AuditKind = (typeof AUDIT_KINDS)[number]
export const auditKindSchema = z.enum(AUDIT_KINDS)
export const AUTH_AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.sso_handoff',
] as const
export const auditLogEntrySchema = z.object({
id: z.string(),
event_id: z.string().nullable(),
@@ -62,7 +74,9 @@ export const auditListQuerySchema = z.object({
action: z.string().optional(),
severity: auditSeveritySchema.optional(),
user_id: z.string().optional(),
actor_email: z.string().email().optional(),
source_app: auditSourceAppSchema.optional(),
kind: auditKindSchema.optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
+18
View File
@@ -371,11 +371,29 @@ export const adminUserSchema = z.object({
apps: z.array(appIdSchema),
permissions: z.array(z.string()),
last_login_at: z.string().nullable(),
last_login_ip: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
export type AdminUser = z.infer<typeof adminUserSchema>
export const adminSessionSchema = z.object({
id: z.string(),
user_id: z.string(),
email: z.string().email(),
name: z.string(),
ip: z.string().nullable(),
user_agent: z.string().nullable(),
created_at: z.string(),
expires_at: z.string(),
})
export type AdminSession = z.infer<typeof adminSessionSchema>
export const ssoAccessRequestSchema = z.object({
return_to: z.string().url(),
})
export type SsoAccessRequest = z.infer<typeof ssoAccessRequestSchema>
export const createUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1),