feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting). Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -27,6 +27,8 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { AppConfig } from './config.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
import { auditAdminRoutes } from './routes/audit.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -116,6 +118,14 @@ export async function buildApp(opts: {
|
||||
await ensureBootstrapAdmin(app)
|
||||
await app.register(authRoutes)
|
||||
await app.register(adminRoutes)
|
||||
await app.register(auditAdminRoutes)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const stopRetention = startAuditRetentionJob(app)
|
||||
app.addHook('onClose', async () => {
|
||||
stopRetention()
|
||||
})
|
||||
}
|
||||
|
||||
if (config.staticDir && existsSync(config.staticDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
appendAudit,
|
||||
type AppendAuditInput,
|
||||
} from '@authportal/db'
|
||||
|
||||
export function clientIp(request: FastifyRequest): string | null {
|
||||
const forwarded = request.headers['x-forwarded-for']
|
||||
if (typeof forwarded === 'string' && forwarded.trim()) {
|
||||
return forwarded.split(',')[0]?.trim() ?? null
|
||||
}
|
||||
return request.ip ?? null
|
||||
}
|
||||
|
||||
export function safeAudit(
|
||||
app: FastifyInstance,
|
||||
input: AppendAuditInput,
|
||||
): void {
|
||||
try {
|
||||
appendAudit(app.db, input)
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log append failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function actorFromRequest(request: FastifyRequest): Pick<
|
||||
AppendAuditInput,
|
||||
'actorUserId' | 'actorEmail' | 'actorName'
|
||||
> {
|
||||
const u = request.authUser
|
||||
if (!u) {
|
||||
return {
|
||||
actorUserId: null,
|
||||
actorEmail: null,
|
||||
actorName: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
actorUserId: u.id,
|
||||
actorEmail: u.email,
|
||||
actorName: u.name,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type AppId,
|
||||
} from '@authportal/shared'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
|
||||
const allowedPermissions = new Set(allPermissionKeys())
|
||||
|
||||
@@ -115,7 +116,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
isAdmin: data.is_admin,
|
||||
})
|
||||
setUserAccess(app.db, user.id, data.apps, permissions)
|
||||
return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!))
|
||||
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.create',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Создан пользователь ${mapped.email}`,
|
||||
details: {
|
||||
is_admin: mapped.is_admin,
|
||||
apps: mapped.apps,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(201).send(mapped)
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
@@ -152,7 +167,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
isAdmin: parsed.data.is_admin,
|
||||
disabled: parsed.data.disabled,
|
||||
})
|
||||
return mapUser(app.db, updated!)
|
||||
const mapped = mapUser(app.db, updated!)
|
||||
safeAudit(app, {
|
||||
action: 'user.update',
|
||||
severity: parsed.data.disabled ? 'warning' : 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Изменён пользователь ${mapped.email}`,
|
||||
details: {
|
||||
...parsed.data,
|
||||
password: parsed.data.password ? '[changed]' : undefined,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
@@ -167,12 +196,28 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const ok = deleteUser(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'user.delete',
|
||||
severity: 'critical',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Удалён пользователь ${existing.email}`,
|
||||
details: { email: existing.email, name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(204).send()
|
||||
},
|
||||
)
|
||||
@@ -205,7 +250,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
parsed.data.apps,
|
||||
permissions,
|
||||
)
|
||||
return mapUser(app.db, getUserById(app.db, request.params.id)!)
|
||||
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.access_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Обновлены права: ${mapped.email}`,
|
||||
details: {
|
||||
apps: mapped.apps,
|
||||
permissions_count: mapped.permissions.length,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
@@ -220,6 +279,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
return setAppSwitcherConfig(app.db, parsed.data)
|
||||
const result = setAppSwitcherConfig(app.db, parsed.data)
|
||||
safeAudit(app, {
|
||||
action: 'app_switcher.update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'app_switcher',
|
||||
summary: 'Обновлены ссылки App Switcher',
|
||||
details: {
|
||||
menuLabel: result.menuLabel,
|
||||
apps: result.apps.map((a) => a.id),
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
listAudit,
|
||||
purgeAuditOlderThan,
|
||||
setAuditRetentionDays,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
auditListQuerySchema,
|
||||
putAuditSettingsSchema,
|
||||
} from '@authportal/shared'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
|
||||
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/v1/admin/audit')) return
|
||||
await requireAdmin(request, reply)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit', async (request, reply) => {
|
||||
const parsed = auditListQuerySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
|
||||
})
|
||||
}
|
||||
return listAudit(app.db, parsed.data)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit/settings', async () => ({
|
||||
retention_days: getAuditRetentionDays(app.db),
|
||||
}))
|
||||
|
||||
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
|
||||
const parsed = putAuditSettingsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'retention_days: от 7 до 3650',
|
||||
},
|
||||
})
|
||||
}
|
||||
const prev = getAuditRetentionDays(app.db)
|
||||
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.settings_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'audit_retention',
|
||||
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
|
||||
details: { previous: prev, retention_days: days },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { retention_days: days }
|
||||
})
|
||||
|
||||
app.post('/api/v1/admin/audit/purge', async (request) => {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'system',
|
||||
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'manual' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { deleted, retention_days: days }
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
publicAppSwitcherConfig,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
createRefreshSession,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from '@authportal/db'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
@@ -39,8 +39,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const ip = clientIp(request)
|
||||
const user = getUserByEmail(app.db, email)
|
||||
if (!user || user.disabled) {
|
||||
safeAudit(app, {
|
||||
action: 'auth.login_failed',
|
||||
severity: 'warning',
|
||||
actorEmail: email.toLowerCase(),
|
||||
targetType: 'session',
|
||||
summary: `Неудачный вход: ${email}`,
|
||||
details: { reason: !user ? 'unknown_user' : 'disabled' },
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
@@ -48,6 +58,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const ok = await verify(user.passwordHash, password)
|
||||
if (!ok) {
|
||||
safeAudit(app, {
|
||||
action: 'auth.login_failed',
|
||||
severity: 'warning',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Неудачный вход: ${user.email}`,
|
||||
details: { reason: 'bad_password' },
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
@@ -66,6 +88,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||
)
|
||||
|
||||
safeAudit(app, {
|
||||
action: 'auth.login',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Вход: ${user.email}`,
|
||||
ip,
|
||||
})
|
||||
|
||||
return body
|
||||
},
|
||||
})
|
||||
@@ -102,6 +136,36 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
)
|
||||
|
||||
// Best-effort actor from JWT if present
|
||||
let actorUserId: string | null = null
|
||||
let actorEmail: string | null = null
|
||||
let actorName: string | null = null
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const sub = request.user.sub
|
||||
const row = getUserById(app.db, sub)
|
||||
if (row) {
|
||||
actorUserId = row.id
|
||||
actorEmail = row.email
|
||||
actorName = row.name
|
||||
}
|
||||
} catch {
|
||||
/* anonymous logout */
|
||||
}
|
||||
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'info',
|
||||
actorUserId,
|
||||
actorEmail,
|
||||
actorName,
|
||||
targetType: 'session',
|
||||
targetId: actorUserId,
|
||||
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
purgeAuditOlderThan,
|
||||
} from '@authportal/db'
|
||||
import { safeAudit } from '../lib/audit.js'
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export function startAuditRetentionJob(app: FastifyInstance): () => void {
|
||||
const run = () => {
|
||||
try {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
if (deleted > 0) {
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'info',
|
||||
targetType: 'system',
|
||||
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'scheduler' },
|
||||
})
|
||||
app.log.info(
|
||||
{ deleted, retention_days: days },
|
||||
'audit_log retention purge',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log retention job failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Defer first run slightly so bootstrap finishes
|
||||
const initial = setTimeout(run, 15_000)
|
||||
const interval = setInterval(run, HOUR_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(initial)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ describe('app-switcher API', () => {
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps'])
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cfdm',
|
||||
'fw',
|
||||
'vps',
|
||||
])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
|
||||
async function buildTestApp() {
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: '[email protected]',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: '[email protected]', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
|
||||
describe('audit log API', () => {
|
||||
it('records login and lists for admin', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string }[]
|
||||
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('updates retention and purges old rows', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
appendAudit(app.db, {
|
||||
action: 'user.create',
|
||||
summary: 'old event',
|
||||
})
|
||||
// Backdate the last inserted row
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
|
||||
)
|
||||
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
|
||||
|
||||
const settings = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/audit/settings',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { retention_days: 30 },
|
||||
})
|
||||
expect(settings.statusCode).toBe(200)
|
||||
expect(settings.json()).toEqual({ retention_days: 30 })
|
||||
|
||||
const before = listAudit(app.db, { limit: 500 })
|
||||
expect(before.some((e) => e.summary === 'old event')).toBe(true)
|
||||
|
||||
const deleted = purgeAuditOlderThan(app.db, 30)
|
||||
expect(deleted).toBeGreaterThanOrEqual(1)
|
||||
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
|
||||
false,
|
||||
)
|
||||
|
||||
const purge = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/audit/purge',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(purge.statusCode).toBe(200)
|
||||
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records user.create from admin mutation', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
email: '[email protected]',
|
||||
name: 'New User',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?action=user.create',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string; summary: string }[]
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1)
|
||||
expect(entries[0]?.summary).toContain('[email protected]')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LayoutGridIcon, UsersIcon, AppWindowIcon } from 'lucide-react'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
HistoryIcon,
|
||||
LayoutGridIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { meQueryOptions } from '@/queries/auth'
|
||||
@@ -60,7 +65,8 @@ export function AppSidebar() {
|
||||
tooltip="Пользователи"
|
||||
isActive={
|
||||
isActive(pathname, '/admin', false) &&
|
||||
!pathname.startsWith('/admin/apps')
|
||||
!pathname.startsWith('/admin/apps') &&
|
||||
!pathname.startsWith('/admin/audit')
|
||||
}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
@@ -68,6 +74,16 @@ export function AppSidebar() {
|
||||
<span>Пользователи</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Журнал"
|
||||
isActive={isActive(pathname, '/admin/audit', false)}
|
||||
render={<Link to="/admin/audit" />}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
<span>Журнал</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Приложения"
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@authportal/ui/components/avatar"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@authportal/ui/components/collapsible"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@authportal/ui/components/empty"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@authportal/ui/components/select"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@authportal/ui/components/tabs"
|
||||
import {
|
||||
AUDIT_DAYS,
|
||||
FILTER_OPTIONS,
|
||||
RANGE_OPTIONS,
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
type AuditEvent,
|
||||
type EventType,
|
||||
} from "./data"
|
||||
import { ChevronRightIcon, CopyIcon, CalendarIcon, DownloadIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
const TOTAL_EVENTS = AUDIT_DAYS.reduce((sum, day) => sum + day.events.length, 0)
|
||||
|
||||
function copyValue(value: string) {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single audit event row (reuses timeline-1 Collapsible-in-Frame grammar) ──
|
||||
function EventRow({
|
||||
event,
|
||||
isLast,
|
||||
step,
|
||||
defaultOpen,
|
||||
}: {
|
||||
event: AuditEvent
|
||||
isLast: boolean
|
||||
step: number
|
||||
defaultOpen: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn("ms-10", isLast ? "pb-0" : "pb-6")}>
|
||||
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimelineTitle className="text-sm font-semibold">
|
||||
{event.action}
|
||||
</TimelineTitle>
|
||||
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
severityDotClass[event.severity]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">{event.time}</span>
|
||||
</div>
|
||||
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
|
||||
{event.icon}
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineContent className="mt-2">
|
||||
<Frame stacked dense spacing="sm">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setOpen(nextOpen)}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full"
|
||||
aria-label={`Toggle ${event.action} details`}
|
||||
>
|
||||
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage
|
||||
src={event.actor.avatar}
|
||||
alt={event.actor.name}
|
||||
/>
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{event.actor.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{event.actor.name}, {event.label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90" aria-hidden="true" />
|
||||
</FrameHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="space-y-3">
|
||||
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<DetailRow label="Target">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.target}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Actor">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor.email}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Source IP">
|
||||
<span className="text-foreground inline-flex min-w-0 items-center gap-2 font-medium tabular-nums">
|
||||
<span className="truncate">{event.ip}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{event.location}
|
||||
</span>
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Session">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.detail.sessionId}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
{event.detail.reason}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
|
||||
<Badge variant="outline" className="gap-1.5 font-mono">
|
||||
{event.ref}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.ref)
|
||||
toast.success("Reference copied", {
|
||||
description: `${event.ref} is on your clipboard.`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
Copy reference
|
||||
</Button>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Frame>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditLogTimeline() {
|
||||
const [filter, setFilter] = React.useState<string[]>(["All"])
|
||||
const [range, setRange] = React.useState("24h")
|
||||
|
||||
const activeFilter = (filter[0] ?? "All") as EventType | "All"
|
||||
|
||||
const visibleDays = React.useMemo(() => {
|
||||
if (activeFilter === "All") return AUDIT_DAYS
|
||||
return AUDIT_DAYS.map((day) => ({
|
||||
...day,
|
||||
events: day.events.filter((event) => event.type === activeFilter),
|
||||
})).filter((day) => day.events.length > 0)
|
||||
}, [activeFilter])
|
||||
|
||||
const visibleCount = visibleDays.reduce(
|
||||
(sum, day) => sum + day.events.length,
|
||||
0
|
||||
)
|
||||
|
||||
const handleExport = () => {
|
||||
toast.success("Export ready", {
|
||||
description: `${visibleCount} events queued as CSV. Link valid for 24 hours.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto w-full max-w-2xl"
|
||||
aria-labelledby="audit-log-title"
|
||||
>
|
||||
{/* ── Content header (title + filter chips + range + export) ── */}
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Audit Log
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{TOTAL_EVENTS} events in Acme Cloud workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => value && setRange(value)}
|
||||
items={RANGE_OPTIONS}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<CalendarIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeFilter}
|
||||
onValueChange={(value) => value && setFilter([value])}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
aria-label="Filter by event type"
|
||||
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="px-1 text-sm after:-bottom-px!"
|
||||
>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{visibleDays.length === 0 ? (
|
||||
<Empty className="min-h-[280px] border-0 bg-transparent">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No {activeFilter} events</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
No {activeFilter} events in the last 24 hours. Try another type or
|
||||
widen the range.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFilter(["All"])}
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{visibleDays.map((day) => (
|
||||
<div key={day.id} className="space-y-4">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{day.date}
|
||||
</h2>
|
||||
<Timeline>
|
||||
{day.events.map((event, index) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
step={index + 1}
|
||||
isLast={index === day.events.length - 1}
|
||||
defaultOpen={day.id === 1 && index < 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
import { CircleCheckIcon, TriangleAlertIcon, ArrowLeftRightIcon, MailIcon, ShieldCheckIcon, UsersIcon, RefreshCwIcon, LogOutIcon, KeyRoundIcon, DatabaseIcon } from "lucide-react"
|
||||
|
||||
// ── Audit log world (Acme Cloud workspace) ──
|
||||
// Severity drives the timeline indicator + the inline severity badge. Event
|
||||
// type drives the filter chips. Each event carries an actor (avatar + email +
|
||||
// IP), a target, and an expandable detail block (session id, reason).
|
||||
|
||||
export type EventSeverity = "info" | "notice" | "critical"
|
||||
|
||||
export type EventType = "Auth" | "Roles" | "SSO/SCIM" | "Sessions" | "API"
|
||||
|
||||
export type AuditActor = {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export type AuditEvent = {
|
||||
id: string
|
||||
ref: string
|
||||
type: EventType
|
||||
action: string
|
||||
label: string
|
||||
target: string
|
||||
severity: EventSeverity
|
||||
time: string
|
||||
actor: AuditActor
|
||||
ip: string
|
||||
location: string
|
||||
icon: React.ReactNode
|
||||
detail: { sessionId: string; reason: string }
|
||||
}
|
||||
|
||||
export type AuditDay = {
|
||||
id: number
|
||||
date: string
|
||||
events: AuditEvent[]
|
||||
}
|
||||
|
||||
export type FilterOption = { value: EventType | "All"; label: string }
|
||||
|
||||
export type RangeOption = { value: string; label: string }
|
||||
|
||||
// ── Filter chips (event-type) ──
|
||||
export const FILTER_OPTIONS: FilterOption[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Auth", label: "Auth" },
|
||||
{ value: "Roles", label: "Roles" },
|
||||
{ value: "SSO/SCIM", label: "SSO/SCIM" },
|
||||
{ value: "Sessions", label: "Sessions" },
|
||||
{ value: "API", label: "API" },
|
||||
]
|
||||
|
||||
// ── Date-range select ──
|
||||
export const RANGE_OPTIONS: RangeOption[] = [
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
{ value: "90d", label: "Last 90 days" },
|
||||
]
|
||||
|
||||
// ── Severity → badge variant + indicator dot ──
|
||||
export const severityVariant: Record<EventSeverity, BadgeProps["variant"]> = {
|
||||
info: "success-outline",
|
||||
notice: "warning-outline",
|
||||
critical: "destructive-outline",
|
||||
}
|
||||
|
||||
export const severityLabel: Record<EventSeverity, string> = {
|
||||
info: "Info",
|
||||
notice: "Notice",
|
||||
critical: "Critical",
|
||||
}
|
||||
|
||||
export const severityDotClass: Record<EventSeverity, string> = {
|
||||
info: "bg-success",
|
||||
notice: "bg-warning",
|
||||
critical: "bg-destructive",
|
||||
}
|
||||
|
||||
const MIRA: AuditActor = {
|
||||
name: "Mira Stone",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MS",
|
||||
}
|
||||
const LEO: AuditActor = {
|
||||
name: "Leo Grant",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LG",
|
||||
}
|
||||
const SANA: AuditActor = {
|
||||
name: "Sana Qureshi",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "SQ",
|
||||
}
|
||||
const SARAH: AuditActor = {
|
||||
name: "Sarah Chen",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "SC",
|
||||
}
|
||||
const DAVID: AuditActor = {
|
||||
name: "David Kim",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
|
||||
initials: "DK",
|
||||
}
|
||||
const KENJI: AuditActor = {
|
||||
name: "Kenji Tan",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "KT",
|
||||
}
|
||||
const OMAR: AuditActor = {
|
||||
name: "Omar Haddad",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
initials: "OH",
|
||||
}
|
||||
const NORA: AuditActor = {
|
||||
name: "Nora Vale",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NV",
|
||||
}
|
||||
|
||||
const authIcon = (
|
||||
<CircleCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const authFailIcon = (
|
||||
<TriangleAlertIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const roleIcon = (
|
||||
<ArrowLeftRightIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const inviteIcon = (
|
||||
<MailIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const ssoIcon = (
|
||||
<ShieldCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const scimIcon = (
|
||||
<UsersIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const mfaIcon = (
|
||||
<RefreshCwIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const sessionIcon = (
|
||||
<LogOutIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const apiIcon = (
|
||||
<KeyRoundIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const exportIcon = (
|
||||
<DatabaseIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
|
||||
// ── Audit events grouped by day (newest first) ──
|
||||
export const AUDIT_DAYS: AuditDay[] = [
|
||||
{
|
||||
id: 1,
|
||||
date: "Today, Jun 17",
|
||||
events: [
|
||||
{
|
||||
id: "e1",
|
||||
ref: "evt_9f3a21c8",
|
||||
type: "Auth",
|
||||
action: "Login failed",
|
||||
label: "Password rejected",
|
||||
target: "[email protected]",
|
||||
severity: "critical",
|
||||
time: "2:14 PM",
|
||||
actor: KENJI,
|
||||
ip: "192.0.2.51",
|
||||
location: "Berlin",
|
||||
icon: authFailIcon,
|
||||
detail: {
|
||||
sessionId: "sess_b71e0d44",
|
||||
reason: "3 failed attempts in 5 minutes, account temporarily locked",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
ref: "evt_71b0a9d2",
|
||||
type: "Roles",
|
||||
action: "Role changed",
|
||||
label: "Member to Admin",
|
||||
target: "Sana Qureshi",
|
||||
severity: "notice",
|
||||
time: "1:02 PM",
|
||||
actor: LEO,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: roleIcon,
|
||||
detail: {
|
||||
sessionId: "sess_c98a2f10",
|
||||
reason: "Promotion approved by Mira Stone, scope raised to Write",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e3",
|
||||
ref: "evt_4c2d80ae",
|
||||
type: "Sessions",
|
||||
action: "Session revoked",
|
||||
label: "Chrome on Windows",
|
||||
target: "David Kim",
|
||||
severity: "notice",
|
||||
time: "11:48 AM",
|
||||
actor: SARAH,
|
||||
ip: "192.0.2.22",
|
||||
location: "Seattle",
|
||||
icon: sessionIcon,
|
||||
detail: {
|
||||
sessionId: "sess_5d1c6b09",
|
||||
reason: "Revoked from a stale device, last active 14 days ago",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e4",
|
||||
ref: "evt_2a6f13bb",
|
||||
type: "Auth",
|
||||
action: "Login success",
|
||||
label: "SSO via Okta",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "9:05 AM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: authIcon,
|
||||
detail: {
|
||||
sessionId: "sess_a02d7e58",
|
||||
reason: "Passkey verified, session valid for 12 hours",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: "Yesterday, Jun 16",
|
||||
events: [
|
||||
{
|
||||
id: "e5",
|
||||
ref: "evt_88e1c5f0",
|
||||
type: "API",
|
||||
action: "API key created",
|
||||
label: "Production, ci-deploy",
|
||||
target: "key_3f9a...c712",
|
||||
severity: "notice",
|
||||
time: "6:21 PM",
|
||||
actor: DAVID,
|
||||
ip: "192.0.2.31",
|
||||
location: "Seattle",
|
||||
icon: apiIcon,
|
||||
detail: {
|
||||
sessionId: "sess_7b40e1aa",
|
||||
reason: "Scopes: deployments:write, logs:read, expires in 90 days",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e6",
|
||||
ref: "evt_15d7a3e9",
|
||||
type: "SSO/SCIM",
|
||||
action: "SSO config changed",
|
||||
label: "Okta to Microsoft Entra ID",
|
||||
target: "Acme Cloud workspace",
|
||||
severity: "critical",
|
||||
time: "4:37 PM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: ssoIcon,
|
||||
detail: {
|
||||
sessionId: "sess_e21f9c03",
|
||||
reason: "Default identity provider switched, 68 members affected",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e7",
|
||||
ref: "evt_6b094d27",
|
||||
type: "SSO/SCIM",
|
||||
action: "SCIM provision",
|
||||
label: "4 members imported",
|
||||
target: "Engineering team",
|
||||
severity: "info",
|
||||
time: "4:30 PM",
|
||||
actor: LEO,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: scimIcon,
|
||||
detail: {
|
||||
sessionId: "sess_d4c7b210",
|
||||
reason: "JIT provisioning from Entra ID, 80 of 80 seats reconciled",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e8",
|
||||
ref: "evt_33a8e0c1",
|
||||
type: "Auth",
|
||||
action: "MFA reset",
|
||||
label: "Authenticator re-enrolled",
|
||||
target: "Omar Haddad",
|
||||
severity: "notice",
|
||||
time: "2:10 PM",
|
||||
actor: SARAH,
|
||||
ip: "192.0.2.22",
|
||||
location: "Seattle",
|
||||
icon: mfaIcon,
|
||||
detail: {
|
||||
sessionId: "sess_9f0b2d6e",
|
||||
reason: "Lost device reported, TOTP factor reset by admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e9",
|
||||
ref: "evt_07c4f2a5",
|
||||
type: "Roles",
|
||||
action: "Member invited",
|
||||
label: "Guest, Support Agent",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "10:55 AM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: inviteIcon,
|
||||
detail: {
|
||||
sessionId: "sess_1ab39e7c",
|
||||
reason: "Invite expires in 7 days, scope set to Read",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: "Jun 15",
|
||||
events: [
|
||||
{
|
||||
id: "e10",
|
||||
ref: "evt_5e2b9114",
|
||||
type: "API",
|
||||
action: "Data export",
|
||||
label: "Audit log, CSV",
|
||||
target: "8,420 events",
|
||||
severity: "notice",
|
||||
time: "5:42 PM",
|
||||
actor: OMAR,
|
||||
ip: "192.0.2.40",
|
||||
location: "Toronto",
|
||||
icon: exportIcon,
|
||||
detail: {
|
||||
sessionId: "sess_4c8d1f93",
|
||||
reason: "Export covered 90 days, download link valid for 24 hours",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e11",
|
||||
ref: "evt_9012ad6f",
|
||||
type: "Sessions",
|
||||
action: "Session revoked",
|
||||
label: "Safari on iOS",
|
||||
target: "Nora Vale",
|
||||
severity: "info",
|
||||
time: "3:18 PM",
|
||||
actor: NORA,
|
||||
ip: "192.0.2.47",
|
||||
location: "Austin",
|
||||
icon: sessionIcon,
|
||||
detail: {
|
||||
sessionId: "sess_2f7a0c61",
|
||||
reason: "Signed out of all other devices from account settings",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e12",
|
||||
ref: "evt_a4f60b38",
|
||||
type: "Auth",
|
||||
action: "Login success",
|
||||
label: "Password, 2FA passed",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "8:47 AM",
|
||||
actor: SANA,
|
||||
ip: "192.0.2.33",
|
||||
location: "London",
|
||||
icon: authIcon,
|
||||
detail: {
|
||||
sessionId: "sess_88be4d02",
|
||||
reason: "Security key verified, new device added to trusted list",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AuditLogTimeline } from "./components/audit-log-timeline"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<AuditLogTimeline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ function breadcrumbs(pathname: string) {
|
||||
if (pathname.startsWith('/admin/apps')) {
|
||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/audit')) {
|
||||
return [{ label: 'Журнал аудита', href: '/admin/audit' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/users/')) {
|
||||
return [
|
||||
{ label: 'Пользователи', href: '/admin' },
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { AuditLogEntry, AuditSeverity } from '@authportal/shared'
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
UserCogIcon,
|
||||
UserPlusIcon,
|
||||
UserXIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { BadgeProps } from '@/components/reui/badge'
|
||||
|
||||
export type AuditFilterId = 'all' | 'auth' | 'users' | 'settings'
|
||||
|
||||
export const AUDIT_FILTER_OPTIONS: { value: AuditFilterId; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'auth', label: 'Auth' },
|
||||
{ value: 'users', label: 'Пользователи' },
|
||||
{ value: 'settings', label: 'Настройки' },
|
||||
]
|
||||
|
||||
export const RANGE_OPTIONS = [
|
||||
{ value: '24h', label: 'За 24 часа' },
|
||||
{ value: '7d', label: 'За 7 дней' },
|
||||
{ value: '30d', label: 'За 30 дней' },
|
||||
{ value: '90d', label: 'За 90 дней' },
|
||||
{ value: 'all', label: 'Весь период' },
|
||||
] as const
|
||||
|
||||
export type AuditRange = (typeof RANGE_OPTIONS)[number]['value']
|
||||
|
||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||
info: 'success-outline',
|
||||
warning: 'warning-outline',
|
||||
critical: 'destructive-outline',
|
||||
}
|
||||
|
||||
export const severityLabel: Record<AuditSeverity, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
critical: 'Critical',
|
||||
}
|
||||
|
||||
export const severityDotClass: Record<AuditSeverity, string> = {
|
||||
info: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
critical: 'bg-destructive',
|
||||
}
|
||||
|
||||
const ACTION_META: Record<
|
||||
string,
|
||||
{ label: string; icon: LucideIcon; filter: AuditFilterId }
|
||||
> = {
|
||||
'auth.login': { label: 'Вход', icon: LogInIcon, filter: 'auth' },
|
||||
'auth.login_failed': {
|
||||
label: 'Неудачный вход',
|
||||
icon: ShieldAlertIcon,
|
||||
filter: 'auth',
|
||||
},
|
||||
'auth.logout': { label: 'Выход', icon: LogOutIcon, filter: 'auth' },
|
||||
'user.create': { label: 'Создание пользователя', icon: UserPlusIcon, filter: 'users' },
|
||||
'user.update': { label: 'Изменение пользователя', icon: UserCogIcon, filter: 'users' },
|
||||
'user.delete': { label: 'Удаление пользователя', icon: UserXIcon, filter: 'users' },
|
||||
'user.access_update': {
|
||||
label: 'Обновление прав',
|
||||
icon: KeyRoundIcon,
|
||||
filter: 'users',
|
||||
},
|
||||
'app_switcher.update': {
|
||||
label: 'App Switcher',
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings',
|
||||
},
|
||||
'audit.settings_update': {
|
||||
label: 'Срок хранения журнала',
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings',
|
||||
},
|
||||
'audit.purge': { label: 'Очистка журнала', icon: Trash2Icon, filter: 'settings' },
|
||||
}
|
||||
|
||||
export function actionMeta(action: string) {
|
||||
return (
|
||||
ACTION_META[action] ?? {
|
||||
label: action,
|
||||
icon: SettingsIcon,
|
||||
filter: 'settings' as AuditFilterId,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesFilter(entry: AuditLogEntry, filter: AuditFilterId) {
|
||||
if (filter === 'all') return true
|
||||
return actionMeta(entry.action).filter === filter
|
||||
}
|
||||
|
||||
export function matchesRange(entry: AuditLogEntry, range: AuditRange) {
|
||||
if (range === 'all') return true
|
||||
const ms =
|
||||
range === '24h'
|
||||
? 24 * 60 * 60 * 1000
|
||||
: range === '7d'
|
||||
? 7 * 24 * 60 * 60 * 1000
|
||||
: range === '30d'
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 90 * 24 * 60 * 60 * 1000
|
||||
return Date.now() - new Date(entry.created_at).getTime() <= ms
|
||||
}
|
||||
|
||||
export function initials(name: string | null, email: string | null) {
|
||||
const source = (name ?? email ?? '?').trim()
|
||||
const parts = source.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return source.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export type AuditDayGroup = {
|
||||
key: string
|
||||
label: string
|
||||
events: AuditLogEntry[]
|
||||
}
|
||||
|
||||
export function groupByDay(entries: AuditLogEntry[]): AuditDayGroup[] {
|
||||
const map = new Map<string, AuditLogEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.created_at)
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
const list = map.get(key) ?? []
|
||||
list.push(entry)
|
||||
map.set(key, list)
|
||||
}
|
||||
return [...map.entries()].map(([key, events]) => ({
|
||||
key,
|
||||
label: new Date(events[0]!.created_at).toLocaleDateString('ru-RU', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
events,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatEventTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function exportAuditCsv(entries: AuditLogEntry[]) {
|
||||
const header = [
|
||||
'created_at',
|
||||
'action',
|
||||
'severity',
|
||||
'actor_email',
|
||||
'actor_name',
|
||||
'summary',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'ip',
|
||||
]
|
||||
const rows = entries.map((e) =>
|
||||
[
|
||||
e.created_at,
|
||||
e.action,
|
||||
e.severity,
|
||||
e.actor_email ?? '',
|
||||
e.actor_name ?? '',
|
||||
e.summary,
|
||||
e.target_type ?? '',
|
||||
e.target_id ?? '',
|
||||
e.ip ?? '',
|
||||
]
|
||||
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob([[header.join(','), ...rows].join('\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Audit log timeline — adapted from @reui/solution-users-6.
|
||||
* Preview: https://reui.io/preview/base/solution-users-6
|
||||
* Docs: https://reui.io/blocks · Timeline: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { AuditLogEntry } from '@authportal/shared'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FilterIcon,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@authportal/ui/components/collapsible'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@authportal/ui/components/empty'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@authportal/ui/components/select'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
||||
import {
|
||||
AUDIT_FILTER_OPTIONS,
|
||||
RANGE_OPTIONS,
|
||||
actionMeta,
|
||||
exportAuditCsv,
|
||||
formatEventTime,
|
||||
groupByDay,
|
||||
initials,
|
||||
matchesFilter,
|
||||
matchesRange,
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
type AuditFilterId,
|
||||
type AuditRange,
|
||||
} from './audit-log-helpers'
|
||||
|
||||
function copyValue(value: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
isLast,
|
||||
step,
|
||||
defaultOpen,
|
||||
}: {
|
||||
event: AuditLogEntry
|
||||
isLast: boolean
|
||||
step: number
|
||||
defaultOpen: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const meta = actionMeta(event.action)
|
||||
const Icon = meta.icon
|
||||
const actorName = event.actor_name ?? event.actor_email ?? 'Система'
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn('ms-10', isLast ? 'pb-0' : 'pb-6')}>
|
||||
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimelineTitle className="text-sm font-semibold">
|
||||
{meta.label}
|
||||
</TimelineTitle>
|
||||
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
severityDotClass[event.severity],
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatEventTime(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
|
||||
<Icon aria-hidden="true" />
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineContent className="mt-2">
|
||||
<Frame stacked dense spacing="sm">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</FrameHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<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="Актор">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor_email ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="IP">
|
||||
<span className="text-foreground truncate font-medium tabular-nums">
|
||||
{event.ip ?? '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Действие">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.action}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
{event.details ? (
|
||||
<pre className="bg-muted/50 text-muted-foreground max-h-40 overflow-auto rounded-md p-2 text-xs">
|
||||
{JSON.stringify(event.details, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Frame>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditLogTimeline({
|
||||
entries,
|
||||
totalCount,
|
||||
}: {
|
||||
entries: AuditLogEntry[]
|
||||
totalCount: number
|
||||
}) {
|
||||
const [filter, setFilter] = useState<AuditFilterId>('all')
|
||||
const [range, setRange] = useState<AuditRange>('7d')
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
entries.filter(
|
||||
(e) => matchesFilter(e, filter) && matchesRange(e, range),
|
||||
),
|
||||
[entries, filter, range],
|
||||
)
|
||||
|
||||
const days = useMemo(() => groupByDay(visible), [visible])
|
||||
|
||||
return (
|
||||
<section className="w-full" aria-labelledby="audit-log-title">
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Журнал аудита
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{totalCount} событий · показано {visible.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => {
|
||||
if (value) setRange(value as AuditRange)
|
||||
}}
|
||||
items={[...RANGE_OPTIONS]}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<CalendarIcon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
exportAuditCsv(visible)
|
||||
toast.success('CSV экспортирован', {
|
||||
description: `${visible.length} событий`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:inline">Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={filter}
|
||||
onValueChange={(value) => {
|
||||
if (value) setFilter(value as AuditFilterId)
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
aria-label="Фильтр по типу"
|
||||
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
|
||||
>
|
||||
{AUDIT_FILTER_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="px-1 text-sm after:-bottom-px!"
|
||||
>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{days.length === 0 ? (
|
||||
<Empty className="min-h-[280px] border-0 bg-transparent">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Нет событий</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Нет записей по текущему фильтру. Сбросьте фильтр или расширьте
|
||||
период.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFilter('all')
|
||||
setRange('all')
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-8">
|
||||
{days.map((day, dayIndex) => (
|
||||
<div key={day.key} className="flex flex-col gap-4">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{day.label}
|
||||
</h2>
|
||||
<Timeline>
|
||||
{day.events.map((event, index) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
step={index + 1}
|
||||
isLast={index === day.events.length - 1}
|
||||
defaultOpen={dayIndex === 0 && index < 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Retention settings row — DNA settings-2.
|
||||
* Preview: https://reui.io/preview/base/settings-2
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@authportal/ui/components/item'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
|
||||
export function AuditRetentionPanel({
|
||||
retentionDays,
|
||||
saving,
|
||||
purging,
|
||||
onSave,
|
||||
onPurge,
|
||||
}: {
|
||||
retentionDays: number
|
||||
saving: boolean
|
||||
purging: boolean
|
||||
onSave: (days: number) => void
|
||||
onPurge: () => void
|
||||
}) {
|
||||
const [days, setDays] = useState(String(retentionDays))
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDays(String(retentionDays))
|
||||
}, [retentionDays])
|
||||
|
||||
const parsed = Number.parseInt(days, 10)
|
||||
const valid = Number.isFinite(parsed) && parsed >= 7 && parsed <= 3650
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Ротация журнала</FrameTitle>
|
||||
<FrameDescription>
|
||||
Записи старше N дней удаляются автоматически каждый час
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4">
|
||||
<Item variant="outline" className="items-start">
|
||||
<ItemMedia variant="icon">
|
||||
<HistoryIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>Срок хранения</ItemTitle>
|
||||
<ItemDescription>
|
||||
От 7 до 3650 дней. Сейчас: {retentionDays} дн.
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={7}
|
||||
max={3650}
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
className="w-24 tabular-nums"
|
||||
aria-label="Дней хранения"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!valid || saving || parsed === retentionDays}
|
||||
onClick={() => onSave(parsed)}
|
||||
>
|
||||
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={purging}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
Очистить сейчас
|
||||
</Button>
|
||||
</div>
|
||||
</Item>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Очистить старые записи?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Будут удалены события старше {retentionDays} дней. Это действие
|
||||
необратимо.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
onPurge()
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends useRender.ComponentProps<"div"> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = useState(defaultValue)
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
),
|
||||
"data-orientation": orientation,
|
||||
"data-slot": "timeline",
|
||||
children,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
"data-slot": "timeline-content",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
type TimelineDateProps = useRender.ComponentProps<"time">
|
||||
|
||||
function TimelineDate({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-date",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "time",
|
||||
render,
|
||||
props: mergeProps<"time">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn(className),
|
||||
"data-slot": "timeline-header",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
type TimelineIndicatorProps = useRender.ComponentProps<"div">
|
||||
|
||||
function TimelineIndicator({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-indicator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
step,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
|
||||
className
|
||||
),
|
||||
"data-completed": step <= activeStep || undefined,
|
||||
"data-slot": "timeline-item",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-separator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"h3">) {
|
||||
const defaultProps = {
|
||||
className: cn("font-medium text-sm", className),
|
||||
"data-slot": "timeline-title",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "h3",
|
||||
render,
|
||||
props: mergeProps<"h3">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { AuditLogEntry, AuditSettings } from '@authportal/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export const auditQueryKey = ['admin', 'audit'] as const
|
||||
export const auditSettingsQueryKey = ['admin', 'audit', 'settings'] as const
|
||||
|
||||
export const auditQueryOptions = queryOptions({
|
||||
queryKey: auditQueryKey,
|
||||
queryFn: () =>
|
||||
api.get<AuditLogEntry[]>('/api/v1/admin/audit?limit=200'),
|
||||
})
|
||||
|
||||
export const auditSettingsQueryOptions = queryOptions({
|
||||
queryKey: auditSettingsQueryKey,
|
||||
queryFn: () => api.get<AuditSettings>('/api/v1/admin/audit/settings'),
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
||||
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 AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -52,6 +53,11 @@ const AuthAdminAppsRoute = AuthAdminAppsRouteImport.update({
|
||||
path: '/apps',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({
|
||||
id: '/audit',
|
||||
path: '/audit',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
@@ -64,6 +70,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin': typeof AuthAdminRouteWithChildren
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin/': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -72,6 +79,7 @@ export interface FileRoutesByTo {
|
||||
'/logout': typeof LogoutRoute
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -83,6 +91,7 @@ export interface FileRoutesById {
|
||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||
'/_auth/apps': typeof AuthAppsRoute
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -94,6 +103,7 @@ export interface FileRouteTypes {
|
||||
| '/admin'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/'
|
||||
| '/admin/users/$userId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -102,6 +112,7 @@ export interface FileRouteTypes {
|
||||
| '/logout'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin'
|
||||
| '/admin/users/$userId'
|
||||
id:
|
||||
@@ -112,6 +123,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/admin'
|
||||
| '/_auth/apps'
|
||||
| '/_auth/admin/apps'
|
||||
| '/_auth/admin/audit'
|
||||
| '/_auth/admin/'
|
||||
| '/_auth/admin/users/$userId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -173,6 +185,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthAdminAppsRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/audit': {
|
||||
id: '/_auth/admin/audit'
|
||||
path: '/audit'
|
||||
fullPath: '/admin/audit'
|
||||
preLoaderRoute: typeof AuthAdminAuditRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/users/$userId': {
|
||||
id: '/_auth/admin/users/$userId'
|
||||
path: '/users/$userId'
|
||||
@@ -185,12 +204,14 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface AuthAdminRouteChildren {
|
||||
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
|
||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
|
||||
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||
AuthAdminAuditRoute: AuthAdminAuditRoute,
|
||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditPurgeResponse,
|
||||
AuditSettings,
|
||||
} from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline'
|
||||
import { AuditRetentionPanel } from '@/components/reui-kit/audit-retention-panel'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
auditQueryKey,
|
||||
auditQueryOptions,
|
||||
auditSettingsQueryKey,
|
||||
auditSettingsQueryOptions,
|
||||
} from '@/queries/audit'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/audit')({
|
||||
component: AdminAuditPage,
|
||||
})
|
||||
|
||||
function AdminAuditPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
data: entries = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(auditQueryOptions)
|
||||
const { data: settings } = useQuery(auditSettingsQueryOptions)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (retention_days: number) =>
|
||||
api.put<AuditSettings>('/api/v1/admin/audit/settings', {
|
||||
retention_days,
|
||||
}),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: auditSettingsQueryKey })
|
||||
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
|
||||
toast.success(`Срок хранения: ${data.retention_days} дн.`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось сохранить',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const purgeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<AuditPurgeResponse>('/api/v1/admin/audit/purge'),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries({ queryKey: auditQueryKey })
|
||||
toast.success(`Удалено записей: ${data.deleted}`)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiError ? err.message : 'Ошибка очистки')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-6">
|
||||
<AuditRetentionPanel
|
||||
retentionDays={settings?.retention_days ?? 90}
|
||||
saving={saveMutation.isPending}
|
||||
purging={purgeMutation.isPending}
|
||||
onSave={(days) => saveMutation.mutate(days)}
|
||||
onPurge={() => purgeMutation.mutate()}
|
||||
/>
|
||||
|
||||
{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 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<AuditLogTimeline
|
||||
entries={entries as AuditLogEntry[]}
|
||||
totalCount={entries.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Admin users directory — DNA solution-users-1.
|
||||
* Preview: https://reui.io/preview/base/solution-users-1
|
||||
* Docs: https://reui.io/blocks · Filters: https://reui.io/docs/components/base/filters
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -5,10 +10,18 @@ import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import {
|
||||
CircleDotIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react'
|
||||
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
@@ -27,8 +40,16 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
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 { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
@@ -51,7 +72,6 @@ import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||
|
||||
/** Same shell as UserAccessSheet / solution-users-1. @see https://reui.io/preview/base/solution-users-1 */
|
||||
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/')({
|
||||
@@ -60,28 +80,144 @@ export const Route = createFileRoute('/_auth/admin/')({
|
||||
|
||||
type TabId = 'all' | 'admins' | 'disabled'
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === 'string' && value.trim() === '')
|
||||
)
|
||||
return false
|
||||
if (values.every((value) => value === null || value === undefined))
|
||||
return false
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function applyFiltersToUsers(data: AdminUser[], filters: Filter[]): AdminUser[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
for (const filter of active) {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
let fieldValue: string | boolean = ''
|
||||
if (field === 'name') fieldValue = item.name
|
||||
else if (field === 'email') fieldValue = item.email
|
||||
else if (field === 'role') fieldValue = item.is_admin ? 'admin' : 'user'
|
||||
else if (field === 'status')
|
||||
fieldValue = item.disabled ? 'disabled' : 'active'
|
||||
|
||||
switch (operator) {
|
||||
case 'is':
|
||||
return values.includes(fieldValue)
|
||||
case 'is_not':
|
||||
return !values.includes(fieldValue)
|
||||
case 'is_any_of':
|
||||
return values.some((v) => fieldValue === v)
|
||||
case 'is_not_any_of':
|
||||
return !values.some((v) => fieldValue === v)
|
||||
case 'contains': {
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||
)
|
||||
}
|
||||
case 'not_contains':
|
||||
return !values.some((v) =>
|
||||
String(fieldValue).toLowerCase().includes(String(v).toLowerCase()),
|
||||
)
|
||||
case 'starts_with':
|
||||
return values.some((v) =>
|
||||
String(fieldValue)
|
||||
.toLowerCase()
|
||||
.startsWith(String(v).toLowerCase()),
|
||||
)
|
||||
case 'empty':
|
||||
return fieldValue === '' || fieldValue == null
|
||||
case 'not_empty':
|
||||
return fieldValue !== '' && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createDefaultFilters(): Filter[] {
|
||||
return [createFilter('name', 'contains', [''])]
|
||||
}
|
||||
|
||||
function userInitials(name: string, email: string) {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||
}
|
||||
return (name || email).slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
function AdminUsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
|
||||
const [tab, setTab] = useState<TabId>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
])
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
icon: <UserIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-44',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
icon: <MailIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-48',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Роль',
|
||||
icon: <ShieldCheckIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'admin', label: 'Админ' },
|
||||
{ value: 'user', label: 'Пользователь' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
icon: <CircleDotIcon className="size-3.5" aria-hidden />,
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'active', label: 'Активен' },
|
||||
{ value: 'disabled', label: 'Отключён' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const tabFiltered = useMemo(() => {
|
||||
let rows = users
|
||||
if (tab === 'admins') rows = rows.filter((u) => u.is_admin)
|
||||
if (tab === 'disabled') rows = rows.filter((u) => u.disabled)
|
||||
const q = search.trim().toLowerCase()
|
||||
if (q) {
|
||||
rows = rows.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}, [users, tab, search])
|
||||
return applyFiltersToUsers(rows, filters)
|
||||
}, [users, tab, filters])
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
@@ -96,19 +232,35 @@ function AdminUsersPage() {
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Имя',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.email}
|
||||
</span>
|
||||
</div>
|
||||
id: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Участник" column={column} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const u = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="text-xs">
|
||||
{userInitials(u.name, u.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{u.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
header: 'Роль',
|
||||
accessorFn: (row) => (row.is_admin ? 1 : 0),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Роль" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.is_admin ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
@@ -122,27 +274,36 @@ function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
header: 'Apps',
|
||||
accessorFn: (row) => row.apps.length,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Apps" column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.apps.length}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
accessorFn: (row) => (row.disabled ? 0 : 1),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Статус" column={column} />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.disabled ? (
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
<Badge variant="destructive-light" size="sm" className="gap-1.5">
|
||||
<span className="bg-destructive size-1.5 shrink-0 rounded-full" />
|
||||
Отключён
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="sm">
|
||||
<Badge variant="success-light" size="sm" className="gap-1.5">
|
||||
<span className="bg-success size-1.5 shrink-0 rounded-full" />
|
||||
Активен
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
@@ -159,11 +320,14 @@ function AdminUsersPage() {
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filtered,
|
||||
data: tabFiltered,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageSize: 10 } },
|
||||
})
|
||||
@@ -193,10 +357,7 @@ function AdminUsersPage() {
|
||||
|
||||
<FramePanel className="flex flex-col gap-4 p-0">
|
||||
<div className="flex flex-col gap-3 border-b px-4 pt-3">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as TabId)}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as TabId)}>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
{(
|
||||
[
|
||||
@@ -219,11 +380,10 @@ function AdminUsersPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="pb-3">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по имени или email…"
|
||||
className="bg-background max-w-sm"
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={setFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +393,12 @@ function AdminUsersPage() {
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
@@ -243,17 +408,30 @@ function AdminUsersPage() {
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : tabFiltered.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
|
||||
<p>Нет пользователей по текущему фильтру.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Создать пользователя
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilters(createDefaultFilters())}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Создать пользователя
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filtered.length}
|
||||
recordCount={tabFiltered.length}
|
||||
tableLayout={{ dense: true, width: 'auto' }}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -262,9 +440,9 @@ function AdminUsersPage() {
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
{!isLoading && filtered.length > 0 ? (
|
||||
{!isLoading && tabFiltered.length > 0 ? (
|
||||
<FrameFooter className="border-t">
|
||||
<DataGrid table={table} recordCount={filtered.length}>
|
||||
<DataGrid table={table} recordCount={tabFiltered.length}>
|
||||
<DataGridPagination />
|
||||
</DataGrid>
|
||||
</FrameFooter>
|
||||
@@ -400,10 +578,7 @@ function CreateUserSheet({
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor="create-is-admin"
|
||||
className="font-normal"
|
||||
>
|
||||
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||
Администратор портала
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
|
||||
@@ -5,7 +5,7 @@ Surface: **ReUI Frame**. Иерархия: **ReUI PRO > shadcn primitives**.
|
||||
|
||||
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
|
||||
|
||||
Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12)
|
||||
Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12)
|
||||
|
||||
## Surface
|
||||
|
||||
@@ -41,7 +41,7 @@ Markup KPI/QuickActions — SoT EvoBGP (diff только `@authportal/ui`).
|
||||
Nav groups Auth Portal:
|
||||
|
||||
- **Портал:** Приложения (`/apps`)
|
||||
- **Админ** (только `is_admin`): Пользователи (`/admin`), Ссылки приложений (`/admin/apps`)
|
||||
- **Админ** (только `is_admin`): Пользователи (`/admin`), Журнал (`/admin/audit`), Ссылки приложений (`/admin/apps`)
|
||||
|
||||
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`.
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||
type AuditLogEntry,
|
||||
type AuditSeverity,
|
||||
type AuditTargetType,
|
||||
} from '@authportal/shared'
|
||||
import type { AppDb } from './index.js'
|
||||
import { auditLog, portalSettings } from './schema/index.js'
|
||||
|
||||
const SETTINGS_ID = 'main'
|
||||
|
||||
export type AppendAuditInput = {
|
||||
action: string
|
||||
severity?: AuditSeverity
|
||||
actorUserId?: string | null
|
||||
actorEmail?: string | null
|
||||
actorName?: string | null
|
||||
targetType?: AuditTargetType | null
|
||||
targetId?: string | null
|
||||
summary: string
|
||||
details?: Record<string, unknown> | null
|
||||
ip?: string | null
|
||||
}
|
||||
|
||||
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
|
||||
let details: Record<string, unknown> | null = null
|
||||
if (row.detailsJson) {
|
||||
try {
|
||||
details = JSON.parse(row.detailsJson) as Record<string, unknown>
|
||||
} catch {
|
||||
details = { raw: row.detailsJson }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
severity: row.severity as AuditSeverity,
|
||||
actor_user_id: row.actorUserId,
|
||||
actor_email: row.actorEmail,
|
||||
actor_name: row.actorName,
|
||||
target_type: (row.targetType as AuditTargetType | null) ?? null,
|
||||
target_id: row.targetId,
|
||||
summary: row.summary,
|
||||
details,
|
||||
ip: row.ip,
|
||||
created_at: row.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Append an audit event. Callers should catch/swallow DB errors. */
|
||||
export function appendAudit(db: AppDb, input: AppendAuditInput): void {
|
||||
const now = new Date().toISOString()
|
||||
db.insert(auditLog)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
action: input.action,
|
||||
severity: input.severity ?? 'info',
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
actorEmail: input.actorEmail ?? null,
|
||||
actorName: input.actorName ?? null,
|
||||
targetType: input.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
summary: input.summary,
|
||||
detailsJson: input.details ? JSON.stringify(input.details) : null,
|
||||
ip: input.ip ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
export function listAudit(
|
||||
db: AppDb,
|
||||
opts: { action?: string; severity?: AuditSeverity; limit?: number } = {},
|
||||
): AuditLogEntry[] {
|
||||
const limit = opts.limit ?? 200
|
||||
const conditions = []
|
||||
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
|
||||
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
: db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
|
||||
export function purgeAuditOlderThan(db: AppDb, days: number): number {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
|
||||
const result = db
|
||||
.delete(auditLog)
|
||||
.where(lt(auditLog.createdAt, cutoff))
|
||||
.run()
|
||||
return result.changes
|
||||
}
|
||||
|
||||
export function getAuditRetentionDays(db: AppDb): number {
|
||||
const row = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
return row?.auditRetentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS
|
||||
}
|
||||
|
||||
export function setAuditRetentionDays(db: AppDb, days: number): number {
|
||||
const now = new Date().toISOString()
|
||||
const existing = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
if (existing) {
|
||||
db.update(portalSettings)
|
||||
.set({ auditRetentionDays: days, updatedAt: now })
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(portalSettings)
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
appSwitcherJson: null,
|
||||
auditRetentionDays: days,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
export function countAudit(db: AppDb): number {
|
||||
const row = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.get()
|
||||
return Number(row?.count ?? 0)
|
||||
}
|
||||
@@ -63,13 +63,41 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
CREATE TABLE IF NOT EXISTS portal_settings (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
app_switcher_json TEXT,
|
||||
audit_retention_days INTEGER NOT NULL DEFAULT 90,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
actor_user_id TEXT,
|
||||
actor_email TEXT,
|
||||
actor_name TEXT,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
ip TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
`)
|
||||
|
||||
// Existing DBs created before audit_retention_days
|
||||
const cols = sqlite
|
||||
.prepare(`PRAGMA table_info(portal_settings)`)
|
||||
.all() as Array<{ name: string }>
|
||||
if (!cols.some((c) => c.name === 'audit_retention_days')) {
|
||||
sqlite.exec(
|
||||
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function healthCheck(sqlite: Sqlite): void {
|
||||
@@ -79,3 +107,4 @@ export function healthCheck(sqlite: Sqlite): void {
|
||||
export * from './schema/index.js'
|
||||
export * from './users.js'
|
||||
export * from './settings.js'
|
||||
export * from './audit-log.js'
|
||||
|
||||
@@ -40,5 +40,21 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
|
||||
export const portalSettings = sqliteTable('portal_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
appSwitcherJson: text('app_switcher_json'),
|
||||
auditRetentionDays: integer('audit_retention_days').notNull().default(90),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
})
|
||||
|
||||
export const auditLog = sqliteTable('audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
action: text('action').notNull(),
|
||||
severity: text('severity').notNull(),
|
||||
actorUserId: text('actor_user_id'),
|
||||
actorEmail: text('actor_email'),
|
||||
actorName: text('actor_name'),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
summary: text('summary').notNull(),
|
||||
detailsJson: text('details_json'),
|
||||
ip: text('ip'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export function setAppSwitcherConfig(
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
appSwitcherJson: json,
|
||||
auditRetentionDays: 90,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
|
||||
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
|
||||
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
|
||||
|
||||
export const AUDIT_TARGET_TYPES = [
|
||||
'user',
|
||||
'settings',
|
||||
'session',
|
||||
'system',
|
||||
] as const
|
||||
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
|
||||
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
|
||||
|
||||
/** Well-known action keys written by the API. */
|
||||
export const AUDIT_ACTIONS = [
|
||||
'auth.login',
|
||||
'auth.login_failed',
|
||||
'auth.logout',
|
||||
'user.create',
|
||||
'user.update',
|
||||
'user.delete',
|
||||
'user.access_update',
|
||||
'app_switcher.update',
|
||||
'audit.settings_update',
|
||||
'audit.purge',
|
||||
] as const
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
|
||||
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
|
||||
|
||||
export const auditLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
action: z.string(),
|
||||
severity: auditSeveritySchema,
|
||||
actor_user_id: z.string().nullable(),
|
||||
actor_email: z.string().nullable(),
|
||||
actor_name: z.string().nullable(),
|
||||
target_type: auditTargetTypeSchema.nullable(),
|
||||
target_id: z.string().nullable(),
|
||||
summary: z.string(),
|
||||
details: z.record(z.string(), z.unknown()).nullable(),
|
||||
ip: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
})
|
||||
export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
|
||||
|
||||
export const auditListQuerySchema = z.object({
|
||||
action: z.string().optional(),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90
|
||||
export const MIN_AUDIT_RETENTION_DAYS = 7
|
||||
export const MAX_AUDIT_RETENTION_DAYS = 3650
|
||||
|
||||
export const auditSettingsSchema = z.object({
|
||||
retention_days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_AUDIT_RETENTION_DAYS)
|
||||
.max(MAX_AUDIT_RETENTION_DAYS),
|
||||
})
|
||||
export type AuditSettings = z.infer<typeof auditSettingsSchema>
|
||||
|
||||
export const putAuditSettingsSchema = auditSettingsSchema
|
||||
export type PutAuditSettings = z.infer<typeof putAuditSettingsSchema>
|
||||
|
||||
export const auditPurgeResponseSchema = z.object({
|
||||
deleted: z.number().int().nonnegative(),
|
||||
retention_days: z.number().int(),
|
||||
})
|
||||
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './contracts/auth.js'
|
||||
export * from './contracts/app-switcher.js'
|
||||
export * from './contracts/audit.js'
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
Reference in New Issue
Block a user