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 type { AppConfig } from './config.js'
|
||||||
import { authRoutes } from './routes/auth.js'
|
import { authRoutes } from './routes/auth.js'
|
||||||
import { adminRoutes } from './routes/admin.js'
|
import { adminRoutes } from './routes/admin.js'
|
||||||
|
import { auditAdminRoutes } from './routes/audit.js'
|
||||||
|
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -116,6 +118,14 @@ export async function buildApp(opts: {
|
|||||||
await ensureBootstrapAdmin(app)
|
await ensureBootstrapAdmin(app)
|
||||||
await app.register(authRoutes)
|
await app.register(authRoutes)
|
||||||
await app.register(adminRoutes)
|
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)) {
|
if (config.staticDir && existsSync(config.staticDir)) {
|
||||||
await app.register(fastifyStatic, {
|
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,
|
type AppId,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||||
|
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||||
|
|
||||||
const allowedPermissions = new Set(allPermissionKeys())
|
const allowedPermissions = new Set(allPermissionKeys())
|
||||||
|
|
||||||
@@ -115,7 +116,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
isAdmin: data.is_admin,
|
isAdmin: data.is_admin,
|
||||||
})
|
})
|
||||||
setUserAccess(app.db, user.id, data.apps, permissions)
|
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 } }>(
|
app.patch<{ Params: { id: string } }>(
|
||||||
@@ -152,7 +167,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
isAdmin: parsed.data.is_admin,
|
isAdmin: parsed.data.is_admin,
|
||||||
disabled: parsed.data.disabled,
|
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)
|
const ok = deleteUser(app.db, request.params.id)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return reply.status(404).send({
|
return reply.status(404).send({
|
||||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
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()
|
return reply.status(204).send()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -205,7 +250,21 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
parsed.data.apps,
|
parsed.data.apps,
|
||||||
permissions,
|
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: 'Некорректные данные' },
|
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,
|
appsMetaFromSwitcher,
|
||||||
loginRequestSchema,
|
loginRequestSchema,
|
||||||
publicAppSwitcherConfig,
|
publicAppSwitcherConfig,
|
||||||
type LoginResponse,
|
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
createRefreshSession,
|
createRefreshSession,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { requireAuth } from '../plugins/auth-guards.js'
|
import { requireAuth } from '../plugins/auth-guards.js'
|
||||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||||
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token'
|
const REFRESH_COOKIE = 'refresh_token'
|
||||||
|
|
||||||
@@ -39,8 +39,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = parsed.data
|
const { email, password } = parsed.data
|
||||||
|
const ip = clientIp(request)
|
||||||
const user = getUserByEmail(app.db, email)
|
const user = getUserByEmail(app.db, email)
|
||||||
if (!user || user.disabled) {
|
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({
|
return reply.status(401).send({
|
||||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||||
})
|
})
|
||||||
@@ -48,6 +58,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const ok = await verify(user.passwordHash, password)
|
const ok = await verify(user.passwordHash, password)
|
||||||
if (!ok) {
|
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({
|
return reply.status(401).send({
|
||||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
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' : ''}`,
|
`${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
|
return body
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -102,6 +136,36 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
`${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 }
|
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)
|
expect(res.statusCode).toBe(200)
|
||||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||||
expect(body.menuLabel).toBeTruthy()
|
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()
|
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 { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { NavUser } from '@/components/nav-user'
|
import { NavUser } from '@/components/nav-user'
|
||||||
import { meQueryOptions } from '@/queries/auth'
|
import { meQueryOptions } from '@/queries/auth'
|
||||||
@@ -60,7 +65,8 @@ export function AppSidebar() {
|
|||||||
tooltip="Пользователи"
|
tooltip="Пользователи"
|
||||||
isActive={
|
isActive={
|
||||||
isActive(pathname, '/admin', false) &&
|
isActive(pathname, '/admin', false) &&
|
||||||
!pathname.startsWith('/admin/apps')
|
!pathname.startsWith('/admin/apps') &&
|
||||||
|
!pathname.startsWith('/admin/audit')
|
||||||
}
|
}
|
||||||
render={<Link to="/admin" />}
|
render={<Link to="/admin" />}
|
||||||
>
|
>
|
||||||
@@ -68,6 +74,16 @@ export function AppSidebar() {
|
|||||||
<span>Пользователи</span>
|
<span>Пользователи</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Журнал"
|
||||||
|
isActive={isActive(pathname, '/admin/audit', false)}
|
||||||
|
render={<Link to="/admin/audit" />}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
<span>Журнал</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip="Приложения"
|
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')) {
|
if (pathname.startsWith('/admin/apps')) {
|
||||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||||
}
|
}
|
||||||
|
if (pathname.startsWith('/admin/audit')) {
|
||||||
|
return [{ label: 'Журнал аудита', href: '/admin/audit' }]
|
||||||
|
}
|
||||||
if (pathname.startsWith('/admin/users/')) {
|
if (pathname.startsWith('/admin/users/')) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Пользователи', href: '/admin' },
|
{ 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 AuthAppsRouteImport } from './routes/_auth.apps'
|
||||||
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||||
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
||||||
|
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
|
||||||
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -52,6 +53,11 @@ const AuthAdminAppsRoute = AuthAdminAppsRouteImport.update({
|
|||||||
path: '/apps',
|
path: '/apps',
|
||||||
getParentRoute: () => AuthAdminRoute,
|
getParentRoute: () => AuthAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthAdminAuditRoute = AuthAdminAuditRouteImport.update({
|
||||||
|
id: '/audit',
|
||||||
|
path: '/audit',
|
||||||
|
getParentRoute: () => AuthAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||||
id: '/users/$userId',
|
id: '/users/$userId',
|
||||||
path: '/users/$userId',
|
path: '/users/$userId',
|
||||||
@@ -64,6 +70,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin': typeof AuthAdminRouteWithChildren
|
'/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
|
'/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/admin/': typeof AuthAdminIndexRoute
|
'/admin/': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -72,6 +79,7 @@ export interface FileRoutesByTo {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
'/admin/apps': typeof AuthAdminAppsRoute
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
|
'/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/admin': typeof AuthAdminIndexRoute
|
'/admin': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -83,6 +91,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/_auth/apps': typeof AuthAppsRoute
|
'/_auth/apps': typeof AuthAppsRoute
|
||||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||||
|
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
||||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -94,6 +103,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin'
|
| '/admin'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
|
| '/admin/audit'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/admin/users/$userId'
|
| '/admin/users/$userId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -102,6 +112,7 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
|
| '/admin/audit'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/admin/users/$userId'
|
| '/admin/users/$userId'
|
||||||
id:
|
id:
|
||||||
@@ -112,6 +123,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/admin'
|
| '/_auth/admin'
|
||||||
| '/_auth/apps'
|
| '/_auth/apps'
|
||||||
| '/_auth/admin/apps'
|
| '/_auth/admin/apps'
|
||||||
|
| '/_auth/admin/audit'
|
||||||
| '/_auth/admin/'
|
| '/_auth/admin/'
|
||||||
| '/_auth/admin/users/$userId'
|
| '/_auth/admin/users/$userId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -173,6 +185,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAdminAppsRouteImport
|
preLoaderRoute: typeof AuthAdminAppsRouteImport
|
||||||
parentRoute: typeof AuthAdminRoute
|
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': {
|
'/_auth/admin/users/$userId': {
|
||||||
id: '/_auth/admin/users/$userId'
|
id: '/_auth/admin/users/$userId'
|
||||||
path: '/users/$userId'
|
path: '/users/$userId'
|
||||||
@@ -185,12 +204,14 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthAdminRouteChildren {
|
interface AuthAdminRouteChildren {
|
||||||
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||||
|
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
|
||||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||||
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||||
|
AuthAdminAuditRoute: AuthAdminAuditRoute,
|
||||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||||
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -5,10 +10,18 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} 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 type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||||
@@ -27,8 +40,16 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
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 { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
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 { Button } from '@authportal/ui/components/button'
|
||||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||||
import { Input } from '@authportal/ui/components/input'
|
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 { api, ApiError } from '@/lib/api-client'
|
||||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
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'
|
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/admin/')({
|
export const Route = createFileRoute('/_auth/admin/')({
|
||||||
@@ -60,28 +80,144 @@ export const Route = createFileRoute('/_auth/admin/')({
|
|||||||
|
|
||||||
type TabId = 'all' | 'admins' | 'disabled'
|
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() {
|
function AdminUsersPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
|
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
|
||||||
const [tab, setTab] = useState<TabId>('all')
|
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 [createOpen, setCreateOpen] = useState(false)
|
||||||
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
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
|
let rows = users
|
||||||
if (tab === 'admins') rows = rows.filter((u) => u.is_admin)
|
if (tab === 'admins') rows = rows.filter((u) => u.is_admin)
|
||||||
if (tab === 'disabled') rows = rows.filter((u) => u.disabled)
|
if (tab === 'disabled') rows = rows.filter((u) => u.disabled)
|
||||||
const q = search.trim().toLowerCase()
|
return applyFiltersToUsers(rows, filters)
|
||||||
if (q) {
|
}, [users, tab, filters])
|
||||||
rows = rows.filter(
|
|
||||||
(u) =>
|
|
||||||
u.name.toLowerCase().includes(q) ||
|
|
||||||
u.email.toLowerCase().includes(q),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}, [users, tab, search])
|
|
||||||
|
|
||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -96,19 +232,35 @@ function AdminUsersPage() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
header: 'Имя',
|
id: 'name',
|
||||||
cell: ({ row }) => (
|
header: ({ column }) => (
|
||||||
<div className="flex flex-col gap-0.5">
|
<DataGridColumnHeader title="Участник" column={column} />
|
||||||
<span className="font-medium">{row.original.name}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{row.original.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
|
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',
|
id: 'role',
|
||||||
header: 'Роль',
|
accessorFn: (row) => (row.is_admin ? 1 : 0),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Роль" column={column} />
|
||||||
|
),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.is_admin ? (
|
row.original.is_admin ? (
|
||||||
<Badge variant="warning-light" size="sm">
|
<Badge variant="warning-light" size="sm">
|
||||||
@@ -122,27 +274,36 @@ function AdminUsersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apps',
|
id: 'apps',
|
||||||
header: 'Apps',
|
accessorFn: (row) => row.apps.length,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Apps" column={column} />
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">{row.original.apps.length}</span>
|
<span className="tabular-nums">{row.original.apps.length}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Статус',
|
accessorFn: (row) => (row.disabled ? 0 : 1),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader title="Статус" column={column} />
|
||||||
|
),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.disabled ? (
|
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>
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
header: '',
|
header: '',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -159,11 +320,14 @@ function AdminUsersPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filtered,
|
data: tabFiltered,
|
||||||
columns,
|
columns,
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
initialState: { pagination: { pageSize: 10 } },
|
initialState: { pagination: { pageSize: 10 } },
|
||||||
})
|
})
|
||||||
@@ -193,10 +357,7 @@ function AdminUsersPage() {
|
|||||||
|
|
||||||
<FramePanel className="flex flex-col gap-4 p-0">
|
<FramePanel className="flex flex-col gap-4 p-0">
|
||||||
<div className="flex flex-col gap-3 border-b px-4 pt-3">
|
<div className="flex flex-col gap-3 border-b px-4 pt-3">
|
||||||
<Tabs
|
<Tabs value={tab} onValueChange={(v) => setTab(v as TabId)}>
|
||||||
value={tab}
|
|
||||||
onValueChange={(v) => setTab(v as TabId)}
|
|
||||||
>
|
|
||||||
<TabsList variant="line" className="gap-5">
|
<TabsList variant="line" className="gap-5">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -219,11 +380,10 @@ function AdminUsersPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<Input
|
<Filters
|
||||||
value={search}
|
filters={filters}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
fields={filterFields}
|
||||||
placeholder="Поиск по имени или email…"
|
onChange={setFilters}
|
||||||
className="bg-background max-w-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +393,12 @@ function AdminUsersPage() {
|
|||||||
<p className="text-destructive text-sm">
|
<p className="text-destructive text-sm">
|
||||||
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
Повторить
|
Повторить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,17 +408,30 @@ function AdminUsersPage() {
|
|||||||
<Skeleton key={i} className="h-10 w-full" />
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : tabFiltered.length === 0 ? (
|
||||||
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
|
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
|
||||||
<p>Нет пользователей по текущему фильтру.</p>
|
<p>Нет пользователей по текущему фильтру.</p>
|
||||||
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
<div className="flex gap-2">
|
||||||
Создать пользователя
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilters(createDefaultFilters())}
|
||||||
|
>
|
||||||
|
Сбросить фильтры
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
Создать пользователя
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={filtered.length}
|
recordCount={tabFiltered.length}
|
||||||
tableLayout={{ dense: true, width: 'auto' }}
|
tableLayout={{ dense: true, width: 'auto' }}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -262,9 +440,9 @@ function AdminUsersPage() {
|
|||||||
</DataGrid>
|
</DataGrid>
|
||||||
)}
|
)}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
{!isLoading && filtered.length > 0 ? (
|
{!isLoading && tabFiltered.length > 0 ? (
|
||||||
<FrameFooter className="border-t">
|
<FrameFooter className="border-t">
|
||||||
<DataGrid table={table} recordCount={filtered.length}>
|
<DataGrid table={table} recordCount={tabFiltered.length}>
|
||||||
<DataGridPagination />
|
<DataGridPagination />
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
</FrameFooter>
|
</FrameFooter>
|
||||||
@@ -400,10 +578,7 @@ function CreateUserSheet({
|
|||||||
checked={isAdmin}
|
checked={isAdmin}
|
||||||
onCheckedChange={(v) => setIsAdmin(v === true)}
|
onCheckedChange={(v) => setIsAdmin(v === true)}
|
||||||
/>
|
/>
|
||||||
<FieldLabel
|
<FieldLabel htmlFor="create-is-admin" className="font-normal">
|
||||||
htmlFor="create-is-admin"
|
|
||||||
className="font-normal"
|
|
||||||
>
|
|
||||||
Администратор портала
|
Администратор портала
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
</Field>
|
</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)
|
Карта: [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
|
## Surface
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ Markup KPI/QuickActions — SoT EvoBGP (diff только `@authportal/ui`).
|
|||||||
Nav groups Auth Portal:
|
Nav groups Auth Portal:
|
||||||
|
|
||||||
- **Портал:** Приложения (`/apps`)
|
- **Портал:** Приложения (`/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`.
|
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 (
|
CREATE TABLE IF NOT EXISTS portal_settings (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
app_switcher_json TEXT,
|
app_switcher_json TEXT,
|
||||||
|
audit_retention_days INTEGER NOT NULL DEFAULT 90,
|
||||||
updated_at TEXT NOT NULL
|
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_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_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_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 {
|
export function healthCheck(sqlite: Sqlite): void {
|
||||||
@@ -79,3 +107,4 @@ export function healthCheck(sqlite: Sqlite): void {
|
|||||||
export * from './schema/index.js'
|
export * from './schema/index.js'
|
||||||
export * from './users.js'
|
export * from './users.js'
|
||||||
export * from './settings.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', {
|
export const portalSettings = sqliteTable('portal_settings', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
appSwitcherJson: text('app_switcher_json'),
|
appSwitcherJson: text('app_switcher_json'),
|
||||||
|
auditRetentionDays: integer('audit_retention_days').notNull().default(90),
|
||||||
updatedAt: text('updated_at').notNull(),
|
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({
|
.values({
|
||||||
id: SETTINGS_ID,
|
id: SETTINGS_ID,
|
||||||
appSwitcherJson: json,
|
appSwitcherJson: json,
|
||||||
|
auditRetentionDays: 90,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run()
|
.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/auth.js'
|
||||||
export * from './contracts/app-switcher.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