feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()}`),
|
||||||
|
|||||||
@@ -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}` : ''}`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)`)
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user