From 26fc8253c363588d8499f6f4263093967f01dd90 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 04:20:47 +0700 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20=D1=81=20=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=BF=D0=B3=D1=80=D0=B5=D0=B9=D0=B4=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting). Co-authored-by: Cursor --- apps/api/src/app.ts | 10 + apps/api/src/lib/audit.ts | 43 ++ apps/api/src/routes/admin.ts | 81 +++- apps/api/src/routes/audit.ts | 74 ++++ apps/api/src/routes/auth.ts | 66 ++- apps/api/src/services/audit-retention.ts | 41 ++ apps/api/test/app-switcher.test.ts | 7 +- apps/api/test/audit.test.ts | 126 ++++++ apps/web/src/components/app-sidebar.tsx | 20 +- .../components/audit-log-timeline.tsx | 356 +++++++++++++++ .../solution-users-6/components/data.tsx | 407 ++++++++++++++++++ .../blocks/solution-users-6/page.tsx | 9 + .../web/src/components/layout/site-header.tsx | 3 + .../components/reui-kit/audit-log-helpers.ts | 193 +++++++++ .../reui-kit/audit-log-timeline.tsx | 379 ++++++++++++++++ .../reui-kit/audit-retention-panel.tsx | 132 ++++++ apps/web/src/components/reui/timeline.tsx | 256 +++++++++++ apps/web/src/queries/audit.ts | 17 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_auth.admin.audit.tsx | 105 +++++ apps/web/src/routes/_auth.admin.index.tsx | 273 +++++++++--- docs/ui-design-contract.md | 4 +- packages/db/src/audit-log.ts | 150 +++++++ packages/db/src/index.ts | 29 ++ packages/db/src/schema/index.ts | 16 + packages/db/src/settings.ts | 1 + packages/shared/src/contracts/audit.ts | 75 ++++ packages/shared/src/index.ts | 1 + packages/ui/src/components/collapsible.tsx | 19 + packages/ui/src/components/empty.tsx | 104 +++++ 30 files changed, 2959 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/lib/audit.ts create mode 100644 apps/api/src/routes/audit.ts create mode 100644 apps/api/src/services/audit-retention.ts create mode 100644 apps/api/test/audit.test.ts create mode 100644 apps/web/src/components/blocks/solution-users-6/components/audit-log-timeline.tsx create mode 100644 apps/web/src/components/blocks/solution-users-6/components/data.tsx create mode 100644 apps/web/src/components/blocks/solution-users-6/page.tsx create mode 100644 apps/web/src/components/reui-kit/audit-log-helpers.ts create mode 100644 apps/web/src/components/reui-kit/audit-log-timeline.tsx create mode 100644 apps/web/src/components/reui-kit/audit-retention-panel.tsx create mode 100644 apps/web/src/components/reui/timeline.tsx create mode 100644 apps/web/src/queries/audit.ts create mode 100644 apps/web/src/routes/_auth.admin.audit.tsx create mode 100644 packages/db/src/audit-log.ts create mode 100644 packages/shared/src/contracts/audit.ts create mode 100644 packages/ui/src/components/collapsible.tsx create mode 100644 packages/ui/src/components/empty.tsx diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0bae774..fc32204 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,6 +27,8 @@ import { randomUUID } from 'node:crypto' import type { AppConfig } from './config.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' +import { auditAdminRoutes } from './routes/audit.js' +import { startAuditRetentionJob } from './services/audit-retention.js' declare module 'fastify' { interface FastifyInstance { @@ -116,6 +118,14 @@ export async function buildApp(opts: { await ensureBootstrapAdmin(app) await app.register(authRoutes) await app.register(adminRoutes) + await app.register(auditAdminRoutes) + + if (process.env.NODE_ENV !== 'test') { + const stopRetention = startAuditRetentionJob(app) + app.addHook('onClose', async () => { + stopRetention() + }) + } if (config.staticDir && existsSync(config.staticDir)) { await app.register(fastifyStatic, { diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..d23f8f3 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -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, + } +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index caf070a..ce68572 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -25,6 +25,7 @@ import { type AppId, } from '@authportal/shared' import { requireAdmin } from '../plugins/auth-guards.js' +import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js' const allowedPermissions = new Set(allPermissionKeys()) @@ -115,7 +116,21 @@ export async function adminRoutes(app: FastifyInstance): Promise { isAdmin: data.is_admin, }) setUserAccess(app.db, user.id, data.apps, permissions) - return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!)) + const mapped = mapUser(app.db, getUserById(app.db, user.id)!) + safeAudit(app, { + action: 'user.create', + severity: 'info', + ...actorFromRequest(request), + targetType: 'user', + targetId: user.id, + summary: `Создан пользователь ${mapped.email}`, + details: { + is_admin: mapped.is_admin, + apps: mapped.apps, + }, + ip: clientIp(request), + }) + return reply.status(201).send(mapped) }) app.patch<{ Params: { id: string } }>( @@ -152,7 +167,21 @@ export async function adminRoutes(app: FastifyInstance): Promise { isAdmin: parsed.data.is_admin, disabled: parsed.data.disabled, }) - return mapUser(app.db, updated!) + const mapped = mapUser(app.db, updated!) + safeAudit(app, { + action: 'user.update', + severity: parsed.data.disabled ? 'warning' : 'info', + ...actorFromRequest(request), + targetType: 'user', + targetId: mapped.id, + summary: `Изменён пользователь ${mapped.email}`, + details: { + ...parsed.data, + password: parsed.data.password ? '[changed]' : undefined, + }, + ip: clientIp(request), + }) + return mapped }, ) @@ -167,12 +196,28 @@ export async function adminRoutes(app: FastifyInstance): Promise { }, }) } + const existing = getUserById(app.db, request.params.id) + if (!existing) { + return reply.status(404).send({ + error: { code: 'NOT_FOUND', message: 'Пользователь не найден' }, + }) + } const ok = deleteUser(app.db, request.params.id) if (!ok) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Пользователь не найден' }, }) } + safeAudit(app, { + action: 'user.delete', + severity: 'critical', + ...actorFromRequest(request), + targetType: 'user', + targetId: existing.id, + summary: `Удалён пользователь ${existing.email}`, + details: { email: existing.email, name: existing.name }, + ip: clientIp(request), + }) return reply.status(204).send() }, ) @@ -205,7 +250,21 @@ export async function adminRoutes(app: FastifyInstance): Promise { parsed.data.apps, permissions, ) - return mapUser(app.db, getUserById(app.db, request.params.id)!) + const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!) + safeAudit(app, { + action: 'user.access_update', + severity: 'info', + ...actorFromRequest(request), + targetType: 'user', + targetId: mapped.id, + summary: `Обновлены права: ${mapped.email}`, + details: { + apps: mapped.apps, + permissions_count: mapped.permissions.length, + }, + ip: clientIp(request), + }) + return mapped }, ) @@ -220,6 +279,20 @@ export async function adminRoutes(app: FastifyInstance): Promise { 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 }) } diff --git a/apps/api/src/routes/audit.ts b/apps/api/src/routes/audit.ts new file mode 100644 index 0000000..6fa104e --- /dev/null +++ b/apps/api/src/routes/audit.ts @@ -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 { + 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 } + }) +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 469b401..db96854 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -6,7 +6,6 @@ import { appsMetaFromSwitcher, loginRequestSchema, publicAppSwitcherConfig, - type LoginResponse, } from '@authportal/shared' import { createRefreshSession, @@ -18,6 +17,7 @@ import { } from '@authportal/db' import { requireAuth } from '../plugins/auth-guards.js' import { issueAccessToken } from '../lib/issue-access-token.js' +import { clientIp, safeAudit } from '../lib/audit.js' const REFRESH_COOKIE = 'refresh_token' @@ -39,8 +39,18 @@ export async function authRoutes(app: FastifyInstance): Promise { } const { email, password } = parsed.data + const ip = clientIp(request) const user = getUserByEmail(app.db, email) if (!user || user.disabled) { + safeAudit(app, { + action: 'auth.login_failed', + severity: 'warning', + actorEmail: email.toLowerCase(), + targetType: 'session', + summary: `Неудачный вход: ${email}`, + details: { reason: !user ? 'unknown_user' : 'disabled' }, + ip, + }) return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' }, }) @@ -48,6 +58,18 @@ export async function authRoutes(app: FastifyInstance): Promise { const ok = await verify(user.passwordHash, password) if (!ok) { + safeAudit(app, { + action: 'auth.login_failed', + severity: 'warning', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'session', + targetId: user.id, + summary: `Неудачный вход: ${user.email}`, + details: { reason: 'bad_password' }, + ip, + }) return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' }, }) @@ -66,6 +88,18 @@ export async function authRoutes(app: FastifyInstance): Promise { `${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`, ) + safeAudit(app, { + action: 'auth.login', + severity: 'info', + actorUserId: user.id, + actorEmail: user.email, + actorName: user.name, + targetType: 'session', + targetId: user.id, + summary: `Вход: ${user.email}`, + ip, + }) + return body }, }) @@ -102,6 +136,36 @@ export async function authRoutes(app: FastifyInstance): Promise { 'Set-Cookie', `${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, ) + + // Best-effort actor from JWT if present + let actorUserId: string | null = null + let actorEmail: string | null = null + let actorName: string | null = null + try { + await request.jwtVerify() + const sub = request.user.sub + const row = getUserById(app.db, sub) + if (row) { + actorUserId = row.id + actorEmail = row.email + actorName = row.name + } + } catch { + /* anonymous logout */ + } + + safeAudit(app, { + action: 'auth.logout', + severity: 'info', + actorUserId, + actorEmail, + actorName, + targetType: 'session', + targetId: actorUserId, + summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход', + ip: clientIp(request), + }) + return { ok: true } }) diff --git a/apps/api/src/services/audit-retention.ts b/apps/api/src/services/audit-retention.ts new file mode 100644 index 0000000..db781ba --- /dev/null +++ b/apps/api/src/services/audit-retention.ts @@ -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) + } +} diff --git a/apps/api/test/app-switcher.test.ts b/apps/api/test/app-switcher.test.ts index bdb289c..6be7d64 100644 --- a/apps/api/test/app-switcher.test.ts +++ b/apps/api/test/app-switcher.test.ts @@ -16,7 +16,12 @@ describe('app-switcher API', () => { expect(res.statusCode).toBe(200) const body = res.json() as { menuLabel: string; apps: { id: string }[] } expect(body.menuLabel).toBeTruthy() - expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps']) + expect(body.apps.map((a) => a.id).sort()).toEqual([ + 'bgp', + 'cfdm', + 'fw', + 'vps', + ]) await app.close() }) diff --git a/apps/api/test/audit.test.ts b/apps/api/test/audit.test.ts new file mode 100644 index 0000000..2c48b46 --- /dev/null +++ b/apps/api/test/audit.test.ts @@ -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: 'admin@test.local', + ADMIN_PASSWORD: 'adminpass', + DATABASE_URL: 'sqlite::memory:', + NODE_ENV: 'test', + }) + return buildApp({ config, databaseUrl: 'sqlite::memory:' }) +} + +async function adminToken(app: Awaited>) { + const login = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'admin@test.local', 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: 'new@test.local', + 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('new@test.local') + + await app.close() + }) +}) diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index 9e48dd0..9c938fd 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,6 +1,11 @@ import { Link, useRouterState } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' -import { LayoutGridIcon, UsersIcon, AppWindowIcon } from 'lucide-react' +import { + AppWindowIcon, + HistoryIcon, + LayoutGridIcon, + UsersIcon, +} from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { NavUser } from '@/components/nav-user' import { meQueryOptions } from '@/queries/auth' @@ -60,7 +65,8 @@ export function AppSidebar() { tooltip="Пользователи" isActive={ isActive(pathname, '/admin', false) && - !pathname.startsWith('/admin/apps') + !pathname.startsWith('/admin/apps') && + !pathname.startsWith('/admin/audit') } render={} > @@ -68,6 +74,16 @@ export function AppSidebar() { Пользователи + + } + > + + Журнал + + 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 ( + + + +
+ + {event.action} + + + + {event.time} +
+ + {event.icon} + +
+ + + + setOpen(nextOpen)} + className="group/collapsible" + > + + +
+ + + + {event.actor.initials} + + + + {event.actor.name}, {event.label} + +
+
+
+ + + +
+ + + {event.target} + + + + + {event.actor.email} + + + + + {event.ip} + + {event.location} + + + + + + {event.detail.sessionId} + + +
+ +

+ {event.detail.reason} +

+ +
+ + {event.ref} + + +
+
+
+
+ +
+
+ ) +} + +function DetailRow({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +export function AuditLogTimeline() { + const [filter, setFilter] = React.useState(["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 ( +
+ {/* ── Content header (title + filter chips + range + export) ── */} +
+
+
+

+ Audit Log +

+

+ {TOTAL_EVENTS} events in Acme Cloud workspace +

+
+ +
+ + + +
+
+ + value && setFilter([value])} + > + + {FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+ + {visibleDays.length === 0 ? ( + + + + + No {activeFilter} events + + No {activeFilter} events in the last 24 hours. Try another type or + widen the range. + + + + + + + ) : ( +
+ {visibleDays.map((day) => ( +
+

+ {day.date} +

+ + {day.events.map((event, index) => ( + + ))} + +
+ ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/solution-users-6/components/data.tsx b/apps/web/src/components/blocks/solution-users-6/components/data.tsx new file mode 100644 index 0000000..4daf019 --- /dev/null +++ b/apps/web/src/components/blocks/solution-users-6/components/data.tsx @@ -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 = { + info: "success-outline", + notice: "warning-outline", + critical: "destructive-outline", +} + +export const severityLabel: Record = { + info: "Info", + notice: "Notice", + critical: "Critical", +} + +export const severityDotClass: Record = { + info: "bg-success", + notice: "bg-warning", + critical: "bg-destructive", +} + +const MIRA: AuditActor = { + name: "Mira Stone", + email: "mira.stone@acmecloud.com", + 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: "leo.grant@acmecloud.com", + 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: "sana.qureshi@acmecloud.com", + 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: "sarah.chen@acmecloud.com", + 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: "david.kim@acmecloud.com", + 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: "kenji.tan@acmecloud.com", + 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: "omar.haddad@acmecloud.com", + 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: "nora.vale@acmecloud.com", + avatar: + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80", + initials: "NV", +} + +const authIcon = ( +