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
+1
View File
@@ -92,6 +92,7 @@ export async function buildApp(opts: {
const app = Fastify({ const app = Fastify({
logger: { level: config.logLevel }, logger: { level: config.logLevel },
trustProxy: true,
}) })
app.decorate('config', config) 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, getUserByEmail,
getUserById, getUserById,
getUserPermissions, getUserPermissions,
listActiveSessions,
listUsers, listUsers,
revokeAllSessionsForUser,
revokeSessionById,
setAppSwitcherConfig, setAppSwitcherConfig,
setUserAccess, setUserAccess,
updateUser, updateUser,
@@ -46,6 +49,7 @@ function mapUser(
apps, apps,
permissions, permissions,
last_login_at: user.lastLoginAt ?? null, last_login_at: user.lastLoginAt ?? null,
last_login_ip: user.lastLoginIp ?? null,
created_at: user.createdAt, created_at: user.createdAt,
updated_at: user.updatedAt, updated_at: user.updatedAt,
} }
@@ -296,4 +300,65 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}) })
return result 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, action: q.action,
severity: q.severity, severity: q.severity,
userId: q.user_id, userId: q.user_id,
actorEmail: q.actor_email,
sourceApp: q.source_app, sourceApp: q.source_app,
kind: q.kind,
limit: q.limit, limit: q.limit,
}) })
}) })
+80 -16
View File
@@ -6,6 +6,7 @@ import {
appsMetaFromSwitcher, appsMetaFromSwitcher,
loginRequestSchema, loginRequestSchema,
publicAppSwitcherConfig, publicAppSwitcherConfig,
ssoAccessRequestSchema,
} from '@authportal/shared' } from '@authportal/shared'
import { import {
createRefreshSession, createRefreshSession,
@@ -19,6 +20,7 @@ import {
import { requireAuth } from '../plugins/auth-guards.js' import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js' import { issueAccessToken } from '../lib/issue-access-token.js'
import { clientIp, safeAudit } from '../lib/audit.js' import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
const REFRESH_COOKIE = 'refresh_token' 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 ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email) const user = getUserByEmail(app.db, email)
if (!user || user.disabled) { if (!user || user.disabled) {
safeAudit(app, { safeAudit(app, {
@@ -49,7 +53,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
actorEmail: email.toLowerCase(), actorEmail: email.toLowerCase(),
targetType: 'session', targetType: 'session',
summary: `Неудачный вход: ${email}`, 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, ip,
}) })
return reply.status(401).send({ return reply.status(401).send({
@@ -68,7 +77,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session', targetType: 'session',
targetId: user.id, targetId: user.id,
summary: `Неудачный вход: ${user.email}`, summary: `Неудачный вход: ${user.email}`,
details: { reason: 'bad_password' }, details: {
reason: 'bad_password',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip, ip,
}) })
return reply.status(401).send({ return reply.status(401).send({
@@ -82,8 +96,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const refreshExpires = new Date( const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000, Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
) )
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires) createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
touchLastLogin(app.db, user.id) ip,
userAgent,
})
touchLastLogin(app.db, user.id, ip)
reply.header( reply.header(
'Set-Cookie', 'Set-Cookie',
@@ -99,6 +116,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session', targetType: 'session',
targetId: user.id, targetId: user.id,
summary: `Вход: ${user.email}`, summary: `Вход: ${user.email}`,
details: {
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip, 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) => { app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? '' const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
@@ -165,6 +232,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
targetType: 'session', targetType: 'session',
targetId: actorUserId, targetId: actorUserId,
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход', summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
details: { user_agent: clientUserAgent(request.headers) },
ip: clientIp(request), ip: clientIp(request),
}) })
@@ -218,15 +286,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}, },
) )
app.get( app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
'/api/v1/catalog', const switcher = getAppSwitcherConfig(app.db)
{ onRequest: requireAuth }, return {
async () => { apps: appsMetaFromSwitcher(switcher),
const switcher = getAppSwitcherConfig(app.db) permissions: PERMISSION_CATALOG,
return { }
apps: appsMetaFromSwitcher(switcher), })
permissions: PERMISSION_CATALOG,
}
},
)
} }
+140
View File
@@ -183,4 +183,144 @@ describe('audit log API', () => {
await app.close() 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: '[email protected]',
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 === '[email protected]')
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: '[email protected]', 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&[email protected]',
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('[email protected]')
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, AppWindowIcon,
HistoryIcon, HistoryIcon,
LayoutGridIcon, LayoutGridIcon,
LogInIcon,
UsersIcon, UsersIcon,
} from 'lucide-react' } from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
@@ -66,7 +67,8 @@ export function AppSidebar() {
isActive={ isActive={
isActive(pathname, '/admin', false) && isActive(pathname, '/admin', false) &&
!pathname.startsWith('/admin/apps') && !pathname.startsWith('/admin/apps') &&
!pathname.startsWith('/admin/audit') !pathname.startsWith('/admin/audit') &&
!pathname.startsWith('/admin/logins')
} }
render={<Link to="/admin" />} render={<Link to="/admin" />}
> >
@@ -76,12 +78,22 @@ export function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <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)} isActive={isActive(pathname, '/admin/audit', false)}
render={<Link to="/admin/audit" />} render={<Link to="/admin/audit" />}
> >
<HistoryIcon className="size-4" /> <HistoryIcon className="size-4" />
<span>Журнал</span> <span>Изменения</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
@@ -16,8 +16,11 @@ function breadcrumbs(pathname: string) {
if (pathname.startsWith('/admin/apps')) { if (pathname.startsWith('/admin/apps')) {
return [{ label: 'Ссылки приложений', href: '/admin/apps' }] return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
} }
if (pathname.startsWith('/admin/logins')) {
return [{ label: 'Журнал входов', href: '/admin/logins' }]
}
if (pathname.startsWith('/admin/audit')) { if (pathname.startsWith('/admin/audit')) {
return [{ label: 'Журнал аудита', href: '/admin/audit' }] return [{ label: 'Журнал изменений', href: '/admin/audit' }]
} }
if (pathname.startsWith('/admin/users/')) { if (pathname.startsWith('/admin/users/')) {
return [ return [
@@ -656,6 +656,26 @@ function createAdminUserColumns(handlers: {
skeleton: <Skeleton className="h-4 w-28" />, 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', id: 'actions',
header: '', header: '',
@@ -73,6 +73,11 @@ const ACTION_META: Record<
filter: 'auth', filter: 'auth',
}, },
'auth.logout': { label: 'Выход', icon: LogOutIcon, 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.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' }, 'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' }, 'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
@@ -117,6 +122,22 @@ export function matchesSourceApp(
return entry.source_app === source 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) { export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
if (range === 'all') return true if (range === 'all') return true
const ms = const ms =
@@ -58,6 +58,7 @@ import {
RANGE_OPTIONS, RANGE_OPTIONS,
SOURCE_APP_OPTIONS, SOURCE_APP_OPTIONS,
actionMeta, actionMeta,
detailString,
exportAuditCsv, exportAuditCsv,
formatEventTime, formatEventTime,
groupByDay, groupByDay,
@@ -68,6 +69,7 @@ import {
severityDotClass, severityDotClass,
severityLabel, severityLabel,
severityVariant, severityVariant,
sourceAppLabel,
type AuditFilterId, type AuditFilterId,
type AuditRange, type AuditRange,
} from './audit-log-helpers' } from './audit-log-helpers'
@@ -108,6 +110,10 @@ function EventRow({
const meta = actionMeta(event.action) const meta = actionMeta(event.action)
const Icon = meta.icon const Icon = meta.icon
const actorName = event.actor_name ?? event.actor_email ?? 'Система' 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 ( return (
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}> <TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
@@ -127,6 +133,9 @@ function EventRow({
/> />
{severityLabel[event.severity]} {severityLabel[event.severity]}
</Badge> </Badge>
<Badge variant="outline" size="sm">
{sourceAppLabel(event.source_app)}
</Badge>
<span className="text-muted-foreground text-xs tabular-nums"> <span className="text-muted-foreground text-xs tabular-nums">
{formatEventTime(event.created_at)} {formatEventTime(event.created_at)}
</span> </span>
@@ -149,16 +158,25 @@ function EventRow({
aria-label={`Подробности: ${meta.label}`} aria-label={`Подробности: ${meta.label}`}
> >
<FrameHeader className="flex grow flex-row items-center justify-between gap-2"> <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"> <Avatar className="size-5">
<AvatarFallback className="text-[10px]"> <AvatarFallback className="text-[10px]">
{initials(event.actor_name, event.actor_email)} {initials(event.actor_name, event.actor_email)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium"> <span className="text-foreground min-w-0 truncate text-sm font-medium">
{actorName} {event.summary || actorName}
{event.summary ? `${event.summary}` : null}
</span> </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> </div>
<ChevronRightIcon <ChevronRightIcon
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90" 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> <CollapsibleContent>
<FramePanel className="flex flex-col gap-3"> <FramePanel className="flex flex-col gap-3">
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<DetailRow label="Цель"> <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="Актор">
<span className="text-foreground truncate font-medium"> <span className="text-foreground truncate font-medium">
{event.actor_email ?? '—'} {event.actor_email ?? '—'}
</span> </span>
</DetailRow> </DetailRow>
<DetailRow label="IP"> <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 ?? '—'} {event.ip ?? '—'}
</span> </span>
</DetailRow> </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="Действие"> <DetailRow label="Действие">
<span className="text-foreground truncate font-mono text-xs"> <span className="text-foreground truncate font-mono text-xs">
{event.action} {event.action}
@@ -204,18 +235,34 @@ function EventRow({
<Badge variant="outline" className="gap-1.5 font-mono"> <Badge variant="outline" className="gap-1.5 font-mono">
{event.id.slice(0, 8)} {event.id.slice(0, 8)}
</Badge> </Badge>
<Button <div className="flex flex-wrap gap-1">
type="button" {userAgent ? (
size="sm" <Button
variant="ghost" type="button"
onClick={() => { size="sm"
copyValue(event.id) variant="ghost"
toast.success('ID скопирован') onClick={() => {
}} copyValue(userAgent)
> toast.success('User-Agent скопирован')
<CopyIcon className="opacity-60" aria-hidden="true" /> }}
Копировать ID >
</Button> <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> </div>
</FramePanel> </FramePanel>
</CollapsibleContent> </CollapsibleContent>
@@ -272,7 +319,7 @@ export function AuditLogTimeline({
id="audit-log-title" id="audit-log-title"
className="text-xl font-semibold tracking-tight" className="text-xl font-semibold tracking-tight"
> >
Журнал аудита Журнал изменений
</h1> </h1>
<p className="text-muted-foreground text-sm leading-5"> <p className="text-muted-foreground text-sm leading-5">
{totalCount} событий · показано {visible.length} {totalCount} событий · показано {visible.length}
@@ -1,17 +1,25 @@
/** /**
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet. * User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2.
* Preview: https://reui.io/preview/base/solution-users-1 * 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 * 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 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 { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
import { formatEventTime } from '@/components/reui-kit/audit-log-helpers'
import { auditQueryOptions } from '@/queries/audit' 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 { Button } from '@authportal/ui/components/button'
import { ScrollArea } from '@authportal/ui/components/scroll-area' import { ScrollArea } from '@authportal/ui/components/scroll-area'
import { Skeleton } from '@authportal/ui/components/skeleton' import { Skeleton } from '@authportal/ui/components/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { import {
Sheet, Sheet,
SheetClose, SheetClose,
@@ -32,12 +40,71 @@ export function UserAuditSheet({
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
}) { }) {
const queryClient = useQueryClient()
const userId = user?.id const userId = user?.id
const { data: entries = [], isLoading, error, refetch } = useQuery({ const email = user?.email
...auditQueryOptions({ userId, limit: 200 }), const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins')
enabled: open && Boolean(userId),
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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
@@ -65,38 +132,138 @@ export function UserAuditSheet({
/> />
</div> </div>
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm"> <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> </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> </SheetHeader>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="p-4"> <div className="p-4">
{error ? ( {active.error ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
{error instanceof ApiError {active.error instanceof ApiError
? error.message ? active.error.message
: 'Ошибка загрузки'} : 'Ошибка загрузки'}
</p> </p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-fit" className="w-fit"
onClick={() => refetch()} onClick={() => active.refetch()}
> >
Повторить Повторить
</Button> </Button>
</div> </div>
) : isLoading ? ( ) : active.isLoading ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" /> <Skeleton key={i} className="h-14 w-full" />
))} ))}
</div> </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 <AuditLogTimeline
entries={entries} entries={
totalCount={entries.length} (tab === 'logins' ? loginsQuery.data : changesQuery.data) ??
[]
}
totalCount={
(
(tab === 'logins'
? loginsQuery.data
: changesQuery.data) ?? []
).length
}
compact compact
lockedUserId={userId} lockedUserId={userId}
/> />
+16
View File
@@ -123,6 +123,22 @@ export async function ssoHandoffReturnTo(returnTo: string): Promise<boolean> {
const allowlist = await ensureReturnToAllowlist() const allowlist = await ensureReturnToAllowlist()
if (!isReturnToAllowed(returnTo, allowlist)) return false if (!isReturnToAllowed(returnTo, allowlist)) return false
const issued = await reissueAccessToken() 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( window.location.href = buildSsoRedirectUrl(
returnTo, returnTo,
issued.access_token, issued.access_token,
+11
View File
@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import type { import type {
AuditKind,
AuditLogEntry, AuditLogEntry,
AuditSettings, AuditSettings,
AuditSourceApp, AuditSourceApp,
@@ -11,29 +12,39 @@ export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
export function auditListQueryKey(opts?: { export function auditListQueryKey(opts?: {
userId?: string userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp | 'all' sourceApp?: AuditSourceApp | 'all'
kind?: AuditKind
}) { }) {
return [ return [
...auditQueryKey, ...auditQueryKey,
'list', 'list',
opts?.userId ?? null, opts?.userId ?? null,
opts?.actorEmail ?? null,
opts?.sourceApp ?? 'all', opts?.sourceApp ?? 'all',
opts?.kind ?? null,
] as const ] as const
} }
export function auditQueryOptions(opts?: { export function auditQueryOptions(opts?: {
userId?: string userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp sourceApp?: AuditSourceApp
kind?: AuditKind
limit?: number limit?: number
}) { }) {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('limit', String(opts?.limit ?? 200)) params.set('limit', String(opts?.limit ?? 200))
if (opts?.userId) params.set('user_id', opts.userId) 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?.sourceApp) params.set('source_app', opts.sourceApp)
if (opts?.kind) params.set('kind', opts.kind)
return queryOptions({ return queryOptions({
queryKey: auditListQueryKey({ queryKey: auditListQueryKey({
userId: opts?.userId, userId: opts?.userId,
actorEmail: opts?.actorEmail,
sourceApp: opts?.sourceApp ?? 'all', sourceApp: opts?.sourceApp ?? 'all',
kind: opts?.kind,
}), }),
queryFn: () => queryFn: () =>
api.get<AuditLogEntry[]>(`/api/v1/admin/audit?${params.toString()}`), 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 AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps' import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit' 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' import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
@@ -58,6 +59,11 @@ const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({
path: '/audit', path: '/audit',
getParentRoute: () => AuthAdminRoute, getParentRoute: () => AuthAdminRoute,
} as any) } as any)
const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({
id: '/logins',
path: '/logins',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId', id: '/users/$userId',
path: '/users/$userId', path: '/users/$userId',
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/apps': typeof AuthAppsRoute '/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute '/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute '/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin/': typeof AuthAdminIndexRoute '/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
} }
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
'/apps': typeof AuthAppsRoute '/apps': typeof AuthAppsRoute
'/admin/apps': typeof AuthAdminAppsRoute '/admin/apps': typeof AuthAdminAppsRoute
'/admin/audit': typeof AuthAdminAuditRoute '/admin/audit': typeof AuthAdminAuditRoute
'/admin/logins': typeof AuthAdminLoginsRoute
'/admin': typeof AuthAdminIndexRoute '/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
} }
@@ -92,6 +100,7 @@ export interface FileRoutesById {
'/_auth/apps': typeof AuthAppsRoute '/_auth/apps': typeof AuthAppsRoute
'/_auth/admin/apps': typeof AuthAdminAppsRoute '/_auth/admin/apps': typeof AuthAdminAppsRoute
'/_auth/admin/audit': typeof AuthAdminAuditRoute '/_auth/admin/audit': typeof AuthAdminAuditRoute
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
'/_auth/admin/': typeof AuthAdminIndexRoute '/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute '/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
} }
@@ -104,6 +113,7 @@ export interface FileRouteTypes {
| '/apps' | '/apps'
| '/admin/apps' | '/admin/apps'
| '/admin/audit' | '/admin/audit'
| '/admin/logins'
| '/admin/' | '/admin/'
| '/admin/users/$userId' | '/admin/users/$userId'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -113,6 +123,7 @@ export interface FileRouteTypes {
| '/apps' | '/apps'
| '/admin/apps' | '/admin/apps'
| '/admin/audit' | '/admin/audit'
| '/admin/logins'
| '/admin' | '/admin'
| '/admin/users/$userId' | '/admin/users/$userId'
id: id:
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
| '/_auth/apps' | '/_auth/apps'
| '/_auth/admin/apps' | '/_auth/admin/apps'
| '/_auth/admin/audit' | '/_auth/admin/audit'
| '/_auth/admin/logins'
| '/_auth/admin/' | '/_auth/admin/'
| '/_auth/admin/users/$userId' | '/_auth/admin/users/$userId'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -192,6 +204,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthAdminAuditRouteImport preLoaderRoute: typeof AuthAdminAuditRouteImport
parentRoute: typeof AuthAdminRoute 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': { '/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId' id: '/_auth/admin/users/$userId'
path: '/users/$userId' path: '/users/$userId'
@@ -205,6 +224,7 @@ declare module '@tanstack/react-router' {
interface AuthAdminRouteChildren { interface AuthAdminRouteChildren {
AuthAdminAppsRoute: typeof AuthAdminAppsRoute AuthAdminAppsRoute: typeof AuthAdminAppsRoute
AuthAdminAuditRoute: typeof AuthAdminAuditRoute AuthAdminAuditRoute: typeof AuthAdminAuditRoute
AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute
AuthAdminIndexRoute: typeof AuthAdminIndexRoute AuthAdminIndexRoute: typeof AuthAdminIndexRoute
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
} }
@@ -212,6 +232,7 @@ interface AuthAdminRouteChildren {
const AuthAdminRouteChildren: AuthAdminRouteChildren = { const AuthAdminRouteChildren: AuthAdminRouteChildren = {
AuthAdminAppsRoute: AuthAdminAppsRoute, AuthAdminAppsRoute: AuthAdminAppsRoute,
AuthAdminAuditRoute: AuthAdminAuditRoute, AuthAdminAuditRoute: AuthAdminAuditRoute,
AuthAdminLoginsRoute: AuthAdminLoginsRoute,
AuthAdminIndexRoute: AuthAdminIndexRoute, AuthAdminIndexRoute: AuthAdminIndexRoute,
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute, AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
} }
+1 -1
View File
@@ -30,7 +30,7 @@ function AdminAuditPage() {
isLoading, isLoading,
error, error,
refetch, refetch,
} = useQuery(auditQueryOptions()) } = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
const { data: settings } = useQuery(auditSettingsQueryOptions) const { data: settings } = useQuery(auditSettingsQueryOptions)
const saveMutation = useMutation({ 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 { randomUUID } from 'node:crypto'
import { import {
DEFAULT_AUDIT_RETENTION_DAYS, DEFAULT_AUDIT_RETENTION_DAYS,
type AuditKind,
type AuditLogEntry, type AuditLogEntry,
type AuditSeverity, type AuditSeverity,
type AuditSourceApp, type AuditSourceApp,
@@ -99,7 +100,9 @@ export function listAudit(
action?: string action?: string
severity?: AuditSeverity severity?: AuditSeverity
userId?: string userId?: string
actorEmail?: string
sourceApp?: AuditSourceApp sourceApp?: AuditSourceApp
kind?: AuditKind
limit?: number limit?: number
} = {}, } = {},
): AuditLogEntry[] { ): AuditLogEntry[] {
@@ -108,7 +111,22 @@ export function listAudit(
if (opts.action) conditions.push(eq(auditLog.action, opts.action)) if (opts.action) conditions.push(eq(auditLog.action, opts.action))
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity)) if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp)) 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( conditions.push(
or( or(
eq(auditLog.actorUserId, opts.userId), 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, is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
last_login_at TEXT, last_login_at TEXT,
last_login_ip TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -58,6 +59,8 @@ export function migrateSchema(sqlite: Sqlite): void {
token_hash TEXT NOT NULL, token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
revoked_at TEXT, revoked_at TEXT,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -110,6 +113,19 @@ export function migrateSchema(sqlite: Sqlite): void {
if (!userCols.some((c) => c.name === 'last_login_at')) { if (!userCols.some((c) => c.name === 'last_login_at')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`) 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 const auditCols = sqlite
.prepare(`PRAGMA table_info(audit_log)`) .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), isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false), disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
lastLoginAt: text('last_login_at'), lastLoginAt: text('last_login_at'),
lastLoginIp: text('last_login_ip'),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(), updatedAt: text('updated_at').notNull(),
}) })
@@ -34,6 +35,8 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
tokenHash: text('token_hash').notNull(), tokenHash: text('token_hash').notNull(),
expiresAt: text('expires_at').notNull(), expiresAt: text('expires_at').notNull(),
revokedAt: text('revoked_at'), revokedAt: text('revoked_at'),
ip: text('ip'),
userAgent: text('user_agent'),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
}) })
+95 -4
View File
@@ -130,10 +130,18 @@ export function setUserAccess(
.run() .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() const now = new Date().toISOString()
db.update(users) db.update(users)
.set({ lastLoginAt: now, updatedAt: now }) .set({
lastLoginAt: now,
lastLoginIp: ip ?? null,
updatedAt: now,
})
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.run() .run()
} }
@@ -143,17 +151,22 @@ export function createRefreshSession(
userId: string, userId: string,
rawToken: string, rawToken: string,
expiresAt: Date, expiresAt: Date,
): void { meta?: { ip?: string | null; userAgent?: string | null },
): string {
const id = randomUUID()
db.insert(refreshSessions) db.insert(refreshSessions)
.values({ .values({
id: randomUUID(), id,
userId, userId,
tokenHash: hashToken(rawToken), tokenHash: hashToken(rawToken),
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
revokedAt: null, revokedAt: null,
ip: meta?.ip ?? null,
userAgent: meta?.userAgent ?? null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
.run() .run()
return id
} }
export function revokeRefreshSession(db: AppDb, rawToken: string): void { 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))) .where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
.run() .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',
'auth.login_failed', 'auth.login_failed',
'auth.logout', 'auth.logout',
'auth.sso_handoff',
'user.create', 'user.create',
'user.update', 'user.update',
'user.delete', 'user.delete',
@@ -40,6 +41,17 @@ export const AUDIT_ACTIONS = [
export type AuditAction = (typeof AUDIT_ACTIONS)[number] export type AuditAction = (typeof AUDIT_ACTIONS)[number]
export const auditActionSchema = z.enum(AUDIT_ACTIONS) 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({ export const auditLogEntrySchema = z.object({
id: z.string(), id: z.string(),
event_id: z.string().nullable(), event_id: z.string().nullable(),
@@ -62,7 +74,9 @@ export const auditListQuerySchema = z.object({
action: z.string().optional(), action: z.string().optional(),
severity: auditSeveritySchema.optional(), severity: auditSeveritySchema.optional(),
user_id: z.string().optional(), user_id: z.string().optional(),
actor_email: z.string().email().optional(),
source_app: auditSourceAppSchema.optional(), source_app: auditSourceAppSchema.optional(),
kind: auditKindSchema.optional(),
limit: z.coerce.number().int().min(1).max(500).default(200), limit: z.coerce.number().int().min(1).max(500).default(200),
}) })
export type AuditListQuery = z.infer<typeof auditListQuerySchema> export type AuditListQuery = z.infer<typeof auditListQuerySchema>
+18
View File
@@ -371,11 +371,29 @@ export const adminUserSchema = z.object({
apps: z.array(appIdSchema), apps: z.array(appIdSchema),
permissions: z.array(z.string()), permissions: z.array(z.string()),
last_login_at: z.string().nullable(), last_login_at: z.string().nullable(),
last_login_ip: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
}) })
export type AdminUser = z.infer<typeof adminUserSchema> 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({ export const createUserRequestSchema = z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().min(1), name: z.string().min(1),