feat(admin): журналы входов и изменений с IP/UA и сессиями
Разделены экраны «Входы» и «Изменения»; логин пишет IP/UA и last_login_ip; SSO handoff и revoke refresh-сессий; улучшены audit-карточки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,6 +92,7 @@ export async function buildApp(opts: {
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
trustProxy: true,
|
||||
})
|
||||
|
||||
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,
|
||||
getUserById,
|
||||
getUserPermissions,
|
||||
listActiveSessions,
|
||||
listUsers,
|
||||
revokeAllSessionsForUser,
|
||||
revokeSessionById,
|
||||
setAppSwitcherConfig,
|
||||
setUserAccess,
|
||||
updateUser,
|
||||
@@ -46,6 +49,7 @@ function mapUser(
|
||||
apps,
|
||||
permissions,
|
||||
last_login_at: user.lastLoginAt ?? null,
|
||||
last_login_ip: user.lastLoginIp ?? null,
|
||||
created_at: user.createdAt,
|
||||
updated_at: user.updatedAt,
|
||||
}
|
||||
@@ -296,4 +300,65 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/sessions', async (request) => {
|
||||
const userId = (request.query as { user_id?: string }).user_id
|
||||
return listActiveSessions(app.db, { userId }).map((s) => ({
|
||||
id: s.id,
|
||||
user_id: s.userId,
|
||||
email: s.email,
|
||||
name: s.name,
|
||||
ip: s.ip,
|
||||
user_agent: s.userAgent,
|
||||
created_at: s.createdAt,
|
||||
expires_at: s.expiresAt,
|
||||
}))
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/sessions/:id',
|
||||
async (request, reply) => {
|
||||
const ok = revokeSessionById(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'session',
|
||||
targetId: request.params.id,
|
||||
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
|
||||
details: { source: 'admin_revoke' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/sessions/revoke-all',
|
||||
async (request, reply) => {
|
||||
const user = getUserById(app.db, request.params.id)
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const revoked = revokeAllSessionsForUser(app.db, user.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
|
||||
details: { revoked, source: 'admin_revoke_all' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { revoked }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
action: q.action,
|
||||
severity: q.severity,
|
||||
userId: q.user_id,
|
||||
actorEmail: q.actor_email,
|
||||
sourceApp: q.source_app,
|
||||
kind: q.kind,
|
||||
limit: q.limit,
|
||||
})
|
||||
})
|
||||
|
||||
+80
-16
@@ -6,6 +6,7 @@ import {
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
publicAppSwitcherConfig,
|
||||
ssoAccessRequestSchema,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
createRefreshSession,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
@@ -39,8 +41,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const { email, password, return_to: returnTo } = parsed.data
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
const targetApp = targetAppFromReturnTo(returnTo)
|
||||
const user = getUserByEmail(app.db, email)
|
||||
if (!user || user.disabled) {
|
||||
safeAudit(app, {
|
||||
@@ -49,7 +53,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
actorEmail: email.toLowerCase(),
|
||||
targetType: 'session',
|
||||
summary: `Неудачный вход: ${email}`,
|
||||
details: { reason: !user ? 'unknown_user' : 'disabled' },
|
||||
details: {
|
||||
reason: !user ? 'unknown_user' : 'disabled',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
@@ -68,7 +77,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Неудачный вход: ${user.email}`,
|
||||
details: { reason: 'bad_password' },
|
||||
details: {
|
||||
reason: 'bad_password',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
@@ -82,8 +96,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const refreshExpires = new Date(
|
||||
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
|
||||
touchLastLogin(app.db, user.id)
|
||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
touchLastLogin(app.db, user.id, ip)
|
||||
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
@@ -99,6 +116,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Вход: ${user.email}`,
|
||||
details: {
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
|
||||
@@ -128,6 +150,51 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
)
|
||||
|
||||
/** Record SSO handoff to a connected app (already authenticated). */
|
||||
app.post(
|
||||
'/api/v1/auth/sso-access',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = ssoAccessRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const returnTo = parsed.data.return_to
|
||||
const targetApp = targetAppFromReturnTo(returnTo)
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
safeAudit(app, {
|
||||
action: 'auth.sso_handoff',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `SSO: ${user.email} → ${targetApp}`,
|
||||
details: {
|
||||
return_to: returnTo,
|
||||
target_app: targetApp,
|
||||
user_agent: userAgent,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return { ok: true, target_app: targetApp }
|
||||
},
|
||||
)
|
||||
|
||||
app.post('/api/v1/auth/logout', async (request, reply) => {
|
||||
const cookie = request.headers.cookie ?? ''
|
||||
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
|
||||
@@ -165,6 +232,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
targetType: 'session',
|
||||
targetId: actorUserId,
|
||||
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
|
||||
details: { user_agent: clientUserAgent(request.headers) },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
@@ -218,15 +286,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/catalog',
|
||||
{ onRequest: requireAuth },
|
||||
async () => {
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
return {
|
||||
apps: appsMetaFromSwitcher(switcher),
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}
|
||||
},
|
||||
)
|
||||
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
return {
|
||||
apps: appsMetaFromSwitcher(switcher),
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,4 +183,144 @@ describe('audit log API', () => {
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login with IP, UA and last_login_ip', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.10',
|
||||
'user-agent': 'VitestBrowser/1.0',
|
||||
},
|
||||
payload: {
|
||||
email: 'admin@test.local',
|
||||
password: 'adminpass',
|
||||
return_to: 'https://vps.example.test/dashboard',
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; last_login_ip: string | null }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.last_login_ip).toBe('203.0.113.10')
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
ip: string | null
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const loginEvt = entries.find((e) => e.action === 'auth.login')
|
||||
expect(loginEvt?.ip).toBe('203.0.113.10')
|
||||
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
|
||||
expect(loginEvt?.details?.target_app).toBe('vps')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records failed login with email and IP', async () => {
|
||||
const app = await buildTestApp()
|
||||
const fail = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrong' },
|
||||
})
|
||||
expect(fail.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
actor_email: string | null
|
||||
ip: string | null
|
||||
}[]
|
||||
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
|
||||
expect(entries[0]?.actor_email).toBe('nobody@test.local')
|
||||
expect(entries[0]?.ip).toBe('198.51.100.7')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records sso-access and separates kind=changes', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sso = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/sso-access',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { return_to: 'https://bgp.example.test/' },
|
||||
})
|
||||
expect(sso.statusCode).toBe(200)
|
||||
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
|
||||
|
||||
const logins = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const loginEntries = logins.json() as { action: string }[]
|
||||
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
|
||||
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
|
||||
|
||||
const changes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=changes',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const changeEntries = changes.json() as { action: string }[]
|
||||
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists and revokes refresh sessions', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sessions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(sessions.statusCode).toBe(200)
|
||||
const rows = sessions.json() as { id: string; user_id: string }[]
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||
const sessionId = rows[0]!.id
|
||||
|
||||
const revoke = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/sessions/${sessionId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(revoke.statusCode).toBe(200)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
|
||||
).toBe(false)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AppWindowIcon,
|
||||
HistoryIcon,
|
||||
LayoutGridIcon,
|
||||
LogInIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
@@ -66,7 +67,8 @@ export function AppSidebar() {
|
||||
isActive={
|
||||
isActive(pathname, '/admin', false) &&
|
||||
!pathname.startsWith('/admin/apps') &&
|
||||
!pathname.startsWith('/admin/audit')
|
||||
!pathname.startsWith('/admin/audit') &&
|
||||
!pathname.startsWith('/admin/logins')
|
||||
}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
@@ -76,12 +78,22 @@ export function AppSidebar() {
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Журнал"
|
||||
tooltip="Входы"
|
||||
isActive={isActive(pathname, '/admin/logins', false)}
|
||||
render={<Link to="/admin/logins" />}
|
||||
>
|
||||
<LogInIcon className="size-4" />
|
||||
<span>Входы</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Изменения"
|
||||
isActive={isActive(pathname, '/admin/audit', false)}
|
||||
render={<Link to="/admin/audit" />}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
<span>Журнал</span>
|
||||
<span>Изменения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -16,8 +16,11 @@ function breadcrumbs(pathname: string) {
|
||||
if (pathname.startsWith('/admin/apps')) {
|
||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/logins')) {
|
||||
return [{ label: 'Журнал входов', href: '/admin/logins' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/audit')) {
|
||||
return [{ label: 'Журнал аудита', href: '/admin/audit' }]
|
||||
return [{ label: 'Журнал изменений', href: '/admin/audit' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/users/')) {
|
||||
return [
|
||||
|
||||
@@ -656,6 +656,26 @@ function createAdminUserColumns(handlers: {
|
||||
skeleton: <Skeleton className="h-4 w-28" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_login_ip',
|
||||
id: 'lastIp',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Последний IP" visibility column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{row.original.last_login_ip ?? '—'}
|
||||
</span>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: 'Последний IP',
|
||||
skeleton: <Skeleton className="h-4 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
|
||||
@@ -73,6 +73,11 @@ const ACTION_META: Record<
|
||||
filter: 'auth',
|
||||
},
|
||||
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
|
||||
'auth.sso_handoff': {
|
||||
label: 'SSO',
|
||||
icon: KeyRoundIcon,
|
||||
filter: 'auth',
|
||||
},
|
||||
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
|
||||
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
|
||||
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
|
||||
@@ -117,6 +122,22 @@ export function matchesSourceApp(
|
||||
return entry.source_app === source
|
||||
}
|
||||
|
||||
export function sourceAppLabel(app: AuditSourceApp | string): string {
|
||||
return (
|
||||
SOURCE_APP_OPTIONS.find((o) => o.value === app)?.label ?? String(app)
|
||||
)
|
||||
}
|
||||
|
||||
export function detailString(
|
||||
details: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!details) return null
|
||||
const v = details[key]
|
||||
if (typeof v === 'string' && v.trim()) return v
|
||||
return null
|
||||
}
|
||||
|
||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||
if (range === 'all') return true
|
||||
const ms =
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
RANGE_OPTIONS,
|
||||
SOURCE_APP_OPTIONS,
|
||||
actionMeta,
|
||||
detailString,
|
||||
exportAuditCsv,
|
||||
formatEventTime,
|
||||
groupByDay,
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
sourceAppLabel,
|
||||
type AuditFilterId,
|
||||
type AuditRange,
|
||||
} from './audit-log-helpers'
|
||||
@@ -108,6 +110,10 @@ function EventRow({
|
||||
const meta = actionMeta(event.action)
|
||||
const Icon = meta.icon
|
||||
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
|
||||
const userAgent = detailString(event.details, 'user_agent')
|
||||
const targetApp =
|
||||
detailString(event.details, 'target_app') ?? event.source_app
|
||||
const reason = detailString(event.details, 'reason')
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
|
||||
@@ -127,6 +133,9 @@ function EventRow({
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<Badge variant="outline" size="sm">
|
||||
{sourceAppLabel(event.source_app)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(event.created_at)}
|
||||
</span>
|
||||
@@ -149,16 +158,25 @@ function EventRow({
|
||||
aria-label={`Подробности: ${meta.label}`}
|
||||
>
|
||||
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{initials(event.actor_name, event.actor_email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{actorName}
|
||||
{event.summary ? ` — ${event.summary}` : null}
|
||||
<span className="text-foreground min-w-0 truncate text-sm font-medium">
|
||||
{event.summary || actorName}
|
||||
</span>
|
||||
{event.ip ? (
|
||||
<span className="text-muted-foreground font-mono text-xs tabular-nums">
|
||||
{event.ip}
|
||||
</span>
|
||||
) : null}
|
||||
{targetApp && targetApp !== 'portal' ? (
|
||||
<Badge variant="info-outline" size="sm">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
|
||||
@@ -170,23 +188,36 @@ function EventRow({
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<DetailRow label="Цель">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.target_type
|
||||
? `${event.target_type}${event.target_id ? `: ${event.target_id}` : ''}`
|
||||
: '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Актор">
|
||||
<DetailRow label="Логин">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor_email ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="IP">
|
||||
<span className="text-foreground truncate font-medium tabular-nums">
|
||||
<span className="text-foreground truncate font-medium tabular-nums font-mono text-xs">
|
||||
{event.ip ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="User-Agent">
|
||||
<span
|
||||
className="text-foreground line-clamp-2 text-xs"
|
||||
title={userAgent ?? undefined}
|
||||
>
|
||||
{userAgent ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Приложение">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{sourceAppLabel(targetApp)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{reason ? (
|
||||
<DetailRow label="Причина">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{reason}
|
||||
</span>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
<DetailRow label="Действие">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.action}
|
||||
@@ -204,18 +235,34 @@ function EventRow({
|
||||
<Badge variant="outline" className="gap-1.5 font-mono">
|
||||
{event.id.slice(0, 8)}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.id)
|
||||
toast.success('ID скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
Копировать ID
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{userAgent ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(userAgent)
|
||||
toast.success('User-Agent скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
UA
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.id)
|
||||
toast.success('ID скопирован')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
ID
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
@@ -272,7 +319,7 @@ export function AuditLogTimeline({
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал аудита
|
||||
Журнал изменений
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* User-scoped audit Sheet — chrome DNA solution-users-1 MemberDetailSheet.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* User-scoped audit Sheet — chrome DNA solution-users-1 / solution-users-2.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1 · https://reui.io/preview/base/solution-users-2
|
||||
* Timeline: https://reui.io/preview/base/solution-users-6
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AdminUser } from '@authportal/shared'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2Icon, XIcon } from 'lucide-react'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { formatEventTime } from '@/components/reui-kit/audit-log-helpers'
|
||||
import { auditQueryOptions } from '@/queries/audit'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
sessionsListQueryKey,
|
||||
sessionsQueryOptions,
|
||||
} from '@/queries/sessions'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { ScrollArea } from '@authportal/ui/components/scroll-area'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -32,12 +40,71 @@ export function UserAuditSheet({
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const userId = user?.id
|
||||
const { data: entries = [], isLoading, error, refetch } = useQuery({
|
||||
...auditQueryOptions({ userId, limit: 200 }),
|
||||
enabled: open && Boolean(userId),
|
||||
const email = user?.email
|
||||
const [tab, setTab] = useState<'logins' | 'changes' | 'sessions'>('logins')
|
||||
|
||||
const loginsQuery = useQuery({
|
||||
...auditQueryOptions({
|
||||
userId,
|
||||
actorEmail: email,
|
||||
kind: 'logins',
|
||||
limit: 200,
|
||||
}),
|
||||
enabled: open && Boolean(userId) && tab === 'logins',
|
||||
})
|
||||
|
||||
const changesQuery = useQuery({
|
||||
...auditQueryOptions({ userId, kind: 'changes', limit: 200 }),
|
||||
enabled: open && Boolean(userId) && tab === 'changes',
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
...sessionsQueryOptions({ userId }),
|
||||
enabled: open && Boolean(userId) && tab === 'sessions',
|
||||
})
|
||||
|
||||
const revokeAll = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<{ revoked: number }>(
|
||||
`/api/v1/admin/users/${userId}/sessions/revoke-all`,
|
||||
),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionsListQueryKey(userId),
|
||||
})
|
||||
toast.success(`Отозвано сессий: ${data.revoked}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось отозвать сессии',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const revokeOne = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/v1/admin/sessions/${id}`),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sessionsListQueryKey(userId),
|
||||
})
|
||||
toast.success('Сессия отозвана')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось отозвать сессию',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const active =
|
||||
tab === 'logins'
|
||||
? loginsQuery
|
||||
: tab === 'changes'
|
||||
? changesQuery
|
||||
: sessionsQuery
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -65,38 +132,138 @@ export function UserAuditSheet({
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="text-muted-foreground border-b px-4 py-2 text-sm">
|
||||
{user?.email ?? 'События пользователя (актор или цель)'}
|
||||
{user?.email ?? 'События пользователя'}
|
||||
{user?.last_login_ip
|
||||
? ` · последний IP ${user.last_login_ip}`
|
||||
: ''}
|
||||
</SheetDescription>
|
||||
<div className="border-b px-4">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'logins' || v === 'changes' || v === 'sessions') {
|
||||
setTab(v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="h-10! w-full justify-start gap-5"
|
||||
>
|
||||
<TabsTrigger
|
||||
value="logins"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Входы
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="changes"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Изменения
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sessions"
|
||||
className="px-0 text-sm after:-bottom-px!"
|
||||
>
|
||||
Сессии
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{error ? (
|
||||
{active.error ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError
|
||||
? error.message
|
||||
{active.error instanceof ApiError
|
||||
? active.error.message
|
||||
: 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
onClick={() => active.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
) : active.isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : tab === 'sessions' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
revokeAll.isPending ||
|
||||
(sessionsQuery.data?.length ?? 0) === 0
|
||||
}
|
||||
onClick={() => revokeAll.mutate()}
|
||||
>
|
||||
Отозвать все
|
||||
</Button>
|
||||
</div>
|
||||
{(sessionsQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет активных сессий
|
||||
</p>
|
||||
) : (
|
||||
(sessionsQuery.data ?? []).map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="border-border flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{s.ip ?? '—'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={revokeOne.isPending}
|
||||
onClick={() => revokeOne.mutate(s.id)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-2 text-xs"
|
||||
title={s.user_agent ?? undefined}
|
||||
>
|
||||
{s.user_agent ?? '—'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(s.created_at)} · до{' '}
|
||||
{formatEventTime(s.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTimeline
|
||||
entries={entries}
|
||||
totalCount={entries.length}
|
||||
entries={
|
||||
(tab === 'logins' ? loginsQuery.data : changesQuery.data) ??
|
||||
[]
|
||||
}
|
||||
totalCount={
|
||||
(
|
||||
(tab === 'logins'
|
||||
? loginsQuery.data
|
||||
: changesQuery.data) ?? []
|
||||
).length
|
||||
}
|
||||
compact
|
||||
lockedUserId={userId}
|
||||
/>
|
||||
|
||||
@@ -123,6 +123,22 @@ export async function ssoHandoffReturnTo(returnTo: string): Promise<boolean> {
|
||||
const allowlist = await ensureReturnToAllowlist()
|
||||
if (!isReturnToAllowed(returnTo, allowlist)) return false
|
||||
const issued = await reissueAccessToken()
|
||||
try {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
void fetch('/api/v1/auth/sso-access', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ return_to: returnTo }),
|
||||
keepalive: true,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
} catch {
|
||||
/* best-effort audit */
|
||||
}
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
returnTo,
|
||||
issued.access_token,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
AuditKind,
|
||||
AuditLogEntry,
|
||||
AuditSettings,
|
||||
AuditSourceApp,
|
||||
@@ -11,29 +12,39 @@ export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
|
||||
|
||||
export function auditListQueryKey(opts?: {
|
||||
userId?: string
|
||||
actorEmail?: string
|
||||
sourceApp?: AuditSourceApp | 'all'
|
||||
kind?: AuditKind
|
||||
}) {
|
||||
return [
|
||||
...auditQueryKey,
|
||||
'list',
|
||||
opts?.userId ?? null,
|
||||
opts?.actorEmail ?? null,
|
||||
opts?.sourceApp ?? 'all',
|
||||
opts?.kind ?? null,
|
||||
] as const
|
||||
}
|
||||
|
||||
export function auditQueryOptions(opts?: {
|
||||
userId?: string
|
||||
actorEmail?: string
|
||||
sourceApp?: AuditSourceApp
|
||||
kind?: AuditKind
|
||||
limit?: number
|
||||
}) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(opts?.limit ?? 200))
|
||||
if (opts?.userId) params.set('user_id', opts.userId)
|
||||
if (opts?.actorEmail) params.set('actor_email', opts.actorEmail)
|
||||
if (opts?.sourceApp) params.set('source_app', opts.sourceApp)
|
||||
if (opts?.kind) params.set('kind', opts.kind)
|
||||
return queryOptions({
|
||||
queryKey: auditListQueryKey({
|
||||
userId: opts?.userId,
|
||||
actorEmail: opts?.actorEmail,
|
||||
sourceApp: opts?.sourceApp ?? 'all',
|
||||
kind: opts?.kind,
|
||||
}),
|
||||
queryFn: () =>
|
||||
api.get<AuditLogEntry[]>(`/api/v1/admin/audit?${params.toString()}`),
|
||||
|
||||
@@ -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 AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
||||
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
|
||||
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
|
||||
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -58,6 +59,11 @@ const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({
|
||||
path: '/audit',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({
|
||||
id: '/logins',
|
||||
path: '/logins',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/admin/': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/admin': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -92,6 +100,7 @@ export interface FileRoutesById {
|
||||
'/_auth/apps': typeof AuthAppsRoute
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -104,6 +113,7 @@ export interface FileRouteTypes {
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/logins'
|
||||
| '/admin/'
|
||||
| '/admin/users/$userId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -113,6 +123,7 @@ export interface FileRouteTypes {
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/logins'
|
||||
| '/admin'
|
||||
| '/admin/users/$userId'
|
||||
id:
|
||||
@@ -124,6 +135,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/apps'
|
||||
| '/_auth/admin/apps'
|
||||
| '/_auth/admin/audit'
|
||||
| '/_auth/admin/logins'
|
||||
| '/_auth/admin/'
|
||||
| '/_auth/admin/users/$userId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -192,6 +204,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthAdminAuditRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/logins': {
|
||||
id: '/_auth/admin/logins'
|
||||
path: '/logins'
|
||||
fullPath: '/admin/logins'
|
||||
preLoaderRoute: typeof AuthAdminLoginsRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/users/$userId': {
|
||||
id: '/_auth/admin/users/$userId'
|
||||
path: '/users/$userId'
|
||||
@@ -205,6 +224,7 @@ declare module '@tanstack/react-router' {
|
||||
interface AuthAdminRouteChildren {
|
||||
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
|
||||
AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute
|
||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -212,6 +232,7 @@ interface AuthAdminRouteChildren {
|
||||
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||
AuthAdminAuditRoute: AuthAdminAuditRoute,
|
||||
AuthAdminLoginsRoute: AuthAdminLoginsRoute,
|
||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function AdminAuditPage() {
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(auditQueryOptions())
|
||||
} = useQuery(auditQueryOptions({ kind: 'changes', limit: 500 }))
|
||||
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
|
||||
@@ -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 {
|
||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||
type AuditKind,
|
||||
type AuditLogEntry,
|
||||
type AuditSeverity,
|
||||
type AuditSourceApp,
|
||||
@@ -99,7 +100,9 @@ export function listAudit(
|
||||
action?: string
|
||||
severity?: AuditSeverity
|
||||
userId?: string
|
||||
actorEmail?: string
|
||||
sourceApp?: AuditSourceApp
|
||||
kind?: AuditKind
|
||||
limit?: number
|
||||
} = {},
|
||||
): AuditLogEntry[] {
|
||||
@@ -108,7 +111,22 @@ export function listAudit(
|
||||
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
|
||||
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
||||
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
|
||||
if (opts.userId) {
|
||||
if (opts.kind === 'logins') {
|
||||
conditions.push(like(auditLog.action, 'auth.%'))
|
||||
} else if (opts.kind === 'changes') {
|
||||
conditions.push(notLike(auditLog.action, 'auth.%'))
|
||||
}
|
||||
if (opts.actorEmail && opts.userId) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()),
|
||||
eq(auditLog.actorUserId, opts.userId),
|
||||
eq(auditLog.targetId, opts.userId),
|
||||
)!,
|
||||
)
|
||||
} else if (opts.actorEmail) {
|
||||
conditions.push(eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()))
|
||||
} else if (opts.userId) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(auditLog.actorUserId, opts.userId),
|
||||
|
||||
@@ -38,6 +38,7 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
last_login_at TEXT,
|
||||
last_login_ip TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -58,6 +59,8 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -110,6 +113,19 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
if (!userCols.some((c) => c.name === 'last_login_at')) {
|
||||
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`)
|
||||
}
|
||||
if (!userCols.some((c) => c.name === 'last_login_ip')) {
|
||||
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_ip TEXT`)
|
||||
}
|
||||
|
||||
const sessionCols = sqlite
|
||||
.prepare(`PRAGMA table_info(refresh_sessions)`)
|
||||
.all() as Array<{ name: string }>
|
||||
if (!sessionCols.some((c) => c.name === 'ip')) {
|
||||
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN ip TEXT`)
|
||||
}
|
||||
if (!sessionCols.some((c) => c.name === 'user_agent')) {
|
||||
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN user_agent TEXT`)
|
||||
}
|
||||
|
||||
const auditCols = sqlite
|
||||
.prepare(`PRAGMA table_info(audit_log)`)
|
||||
|
||||
@@ -8,6 +8,7 @@ export const users = sqliteTable('users', {
|
||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
|
||||
lastLoginAt: text('last_login_at'),
|
||||
lastLoginIp: text('last_login_ip'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
})
|
||||
@@ -34,6 +35,8 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
revokedAt: text('revoked_at'),
|
||||
ip: text('ip'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
|
||||
@@ -130,10 +130,18 @@ export function setUserAccess(
|
||||
.run()
|
||||
}
|
||||
|
||||
export function touchLastLogin(db: AppDb, userId: string): void {
|
||||
export function touchLastLogin(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
ip?: string | null,
|
||||
): void {
|
||||
const now = new Date().toISOString()
|
||||
db.update(users)
|
||||
.set({ lastLoginAt: now, updatedAt: now })
|
||||
.set({
|
||||
lastLoginAt: now,
|
||||
lastLoginIp: ip ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.run()
|
||||
}
|
||||
@@ -143,17 +151,22 @@ export function createRefreshSession(
|
||||
userId: string,
|
||||
rawToken: string,
|
||||
expiresAt: Date,
|
||||
): void {
|
||||
meta?: { ip?: string | null; userAgent?: string | null },
|
||||
): string {
|
||||
const id = randomUUID()
|
||||
db.insert(refreshSessions)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
id,
|
||||
userId,
|
||||
tokenHash: hashToken(rawToken),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
revokedAt: null,
|
||||
ip: meta?.ip ?? null,
|
||||
userAgent: meta?.userAgent ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
export function revokeRefreshSession(db: AppDb, rawToken: string): void {
|
||||
@@ -163,3 +176,81 @@ export function revokeRefreshSession(db: AppDb, rawToken: string): void {
|
||||
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
|
||||
.run()
|
||||
}
|
||||
|
||||
export type ActiveSessionRow = {
|
||||
id: string
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export function listActiveSessions(
|
||||
db: AppDb,
|
||||
opts: { userId?: string } = {},
|
||||
): ActiveSessionRow[] {
|
||||
const now = new Date().toISOString()
|
||||
const rows = db
|
||||
.select({
|
||||
id: refreshSessions.id,
|
||||
userId: refreshSessions.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
ip: refreshSessions.ip,
|
||||
userAgent: refreshSessions.userAgent,
|
||||
createdAt: refreshSessions.createdAt,
|
||||
expiresAt: refreshSessions.expiresAt,
|
||||
revokedAt: refreshSessions.revokedAt,
|
||||
})
|
||||
.from(refreshSessions)
|
||||
.innerJoin(users, eq(refreshSessions.userId, users.id))
|
||||
.all()
|
||||
|
||||
return rows
|
||||
.filter((r) => {
|
||||
if (r.revokedAt) return false
|
||||
if (r.expiresAt < now) return false
|
||||
if (opts.userId && r.userId !== opts.userId) return false
|
||||
return true
|
||||
})
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
email: r.email,
|
||||
name: r.name,
|
||||
ip: r.ip,
|
||||
userAgent: r.userAgent,
|
||||
createdAt: r.createdAt,
|
||||
expiresAt: r.expiresAt,
|
||||
}))
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
}
|
||||
|
||||
export function revokeSessionById(db: AppDb, sessionId: string): boolean {
|
||||
const row = db
|
||||
.select()
|
||||
.from(refreshSessions)
|
||||
.where(eq(refreshSessions.id, sessionId))
|
||||
.get()
|
||||
if (!row || row.revokedAt) return false
|
||||
db.update(refreshSessions)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(refreshSessions.id, sessionId))
|
||||
.run()
|
||||
return true
|
||||
}
|
||||
|
||||
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
|
||||
const now = new Date().toISOString()
|
||||
const active = listActiveSessions(db, { userId })
|
||||
for (const s of active) {
|
||||
db.update(refreshSessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(refreshSessions.id, s.id))
|
||||
.run()
|
||||
}
|
||||
return active.length
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AUDIT_ACTIONS = [
|
||||
'auth.login',
|
||||
'auth.login_failed',
|
||||
'auth.logout',
|
||||
'auth.sso_handoff',
|
||||
'user.create',
|
||||
'user.update',
|
||||
'user.delete',
|
||||
@@ -40,6 +41,17 @@ export const AUDIT_ACTIONS = [
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
|
||||
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
|
||||
|
||||
export const AUDIT_KINDS = ['logins', 'changes'] as const
|
||||
export type AuditKind = (typeof AUDIT_KINDS)[number]
|
||||
export const auditKindSchema = z.enum(AUDIT_KINDS)
|
||||
|
||||
export const AUTH_AUDIT_ACTIONS = [
|
||||
'auth.login',
|
||||
'auth.login_failed',
|
||||
'auth.logout',
|
||||
'auth.sso_handoff',
|
||||
] as const
|
||||
|
||||
export const auditLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
event_id: z.string().nullable(),
|
||||
@@ -62,7 +74,9 @@ export const auditListQuerySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
user_id: z.string().optional(),
|
||||
actor_email: z.string().email().optional(),
|
||||
source_app: auditSourceAppSchema.optional(),
|
||||
kind: auditKindSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
||||
|
||||
@@ -371,11 +371,29 @@ export const adminUserSchema = z.object({
|
||||
apps: z.array(appIdSchema),
|
||||
permissions: z.array(z.string()),
|
||||
last_login_at: z.string().nullable(),
|
||||
last_login_ip: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||
|
||||
export const adminSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
ip: z.string().nullable(),
|
||||
user_agent: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
expires_at: z.string(),
|
||||
})
|
||||
export type AdminSession = z.infer<typeof adminSessionSchema>
|
||||
|
||||
export const ssoAccessRequestSchema = z.object({
|
||||
return_to: z.string().url(),
|
||||
})
|
||||
export type SsoAccessRequest = z.infer<typeof ssoAccessRequestSchema>
|
||||
|
||||
export const createUserRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
|
||||
Reference in New Issue
Block a user