feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m37s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

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